diff --git a/appinfo/info.xml b/appinfo/info.xml
index 2498a2620..75b1ba5e6 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -12,7 +12,7 @@
Those groups of people can then be used by any other app for sharing purpose.
]]>
- 32.0.0-dev.0
+ 31.0.0-dev.1
agpl
Maxence Lange
@@ -31,7 +31,7 @@ Those groups of people can then be used by any other app for sharing purpose.
https://github.com/nextcloud/circles.git
https://raw.githubusercontent.com/nextcloud/circles/master/screenshots/0.12.0.png
-
+
@@ -45,6 +45,7 @@ Those groups of people can then be used by any other app for sharing purpose.
+ OCA\Circles\Command\Test
OCA\Circles\Command\CirclesCreate
OCA\Circles\Command\MembersAdd
OCA\Circles\Command\CirclesJoin
diff --git a/lib/Api/v2/ITeamEntityOperation.php b/lib/Api/v2/ITeamEntityOperation.php
new file mode 100644
index 000000000..dd9edad24
--- /dev/null
+++ b/lib/Api/v2/ITeamEntityOperation.php
@@ -0,0 +1,21 @@
+
+ * @copyright 2017
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Circles\Api\v2;
+
+use OCA\Circles\Model\TeamEntity;
+use OCP\IUser;
+
+interface ITeamSession {
+ public function sessionAsCurrentUser(): self;
+ public function sessionAsUser(IUser $user): self;
+ public function sessionAsLocalUser(string $userId): self;
+ public function sessionAsApp(string $appId): self;
+ public function sessionAsSuperAdmin(): self;
+ public function sessionAsEntity(TeamEntity $entity): self;
+ public function hasEntity(): bool;
+ public function getEntity(): TeamEntity;
+ public function performTeamOperation(): ITeamOperation;
+ public function performTeamMemberOperation(): ITeamMemberOperation;
+}
diff --git a/lib/Api/v2/ITeamSuperOperation.php b/lib/Api/v2/ITeamSuperOperation.php
new file mode 100644
index 000000000..1ee95ec4a
--- /dev/null
+++ b/lib/Api/v2/ITeamSuperOperation.php
@@ -0,0 +1,13 @@
+session !== null) {
+ throw new \Exception('cannot overwrite session'); // TODO specific Exception
+ }
+ if (!$session->hasEntity()) {
+ throw new \Exception('entity must be set'); // TODO: specific exception
+ }
+ $this->session = $session;
+ }
+
+ final public function getSession(): TeamSession {
+ return $this->session;
+ }
+
+ final public function hasEntity(): bool {
+ return $this->session->hasEntity();
+ }
+
+ final public function getEntity(): TeamEntity {
+ return $this->session->getEntity();
+ }
+//
+//
+// final public function asEntity(TeamEntity $entity): void {
+// if ($this->entity !== null) {
+// throw new \Exception('cannot overwrite initiator'); // TODO specific Exception
+// }
+// $this->entity = $entity;
+// }
+
+ /**
+ * @param TeamEntityType|TeamEntityType[] $limitToEntities
+ */
+ final public function confirmSessionInitialized(TeamEntityType|array $limitToEntities = []): void {
+ if ($this->session === null) {
+ throw new \Exception('session/entity not initialized'); // TODO specific Exception
+ }
+
+ if (!empty($limitToEntities)
+ && !in_array($this->getEntity()->getTeamEntityType(), $limitToEntities)) {
+ throw new \Exception('entity cannot execute this operation'); // TODO specific Exception
+ }
+
+ }
+
+ /**
+ * @param TeamEntityType|TeamEntityType[] $limitToEntities
+ */
+ final protected function lowPriorityProcess(TeamEntityType|array $limitToEntities = []): void {
+ $this->confirmSessionInitialized($limitToEntities);
+ // TODO: detect type of process and filters it
+// if (!in_array(
+// $this->entity->getTeamEntityType(),
+// TeamEntityType::OCC,
+// TeamEntityType::CRON,
+// TeamEntityType::ASYNC,
+// )) {
+// throw new \Exception('cannot be executed on main process');
+// }
+ }
+
+}
diff --git a/lib/Api/v2/Operation/TeamEntityOperation.php b/lib/Api/v2/Operation/TeamEntityOperation.php
new file mode 100644
index 000000000..bfee5f9a5
--- /dev/null
+++ b/lib/Api/v2/Operation/TeamEntityOperation.php
@@ -0,0 +1,45 @@
+teamEntityService->generateTeamEntityFromLocalUser($userId);
+ }
+
+ public function getFromTeam(string $singleId): TeamEntity {
+ return $this->teamEntityService->generateTeamEntityFromTeam($singleId);
+ }
+
+ public function getFromApp(string $appId): TeamEntity {
+ return $this->teamEntityService->generateTeamEntityFromApp($appId);
+ }
+
+ public function getFromOcc(): TeamEntity {
+ return $this->teamEntityService->generateTeamEntityFromOcc();
+ }
+
+ public function getFromSuperAdmin(): TeamEntity {
+ return $this->teamEntityService->generateTeamEntityFromSuperAdmin();
+ }
+
+ public function getFromUser(IUser $user): TeamEntity {
+ return $this->teamEntityService->generateTeamEntityFromUser($user);
+ }
+}
diff --git a/lib/Api/v2/Operation/TeamMemberOperation.php b/lib/Api/v2/Operation/TeamMemberOperation.php
new file mode 100644
index 000000000..c628fc720
--- /dev/null
+++ b/lib/Api/v2/Operation/TeamMemberOperation.php
@@ -0,0 +1,53 @@
+confirmSessionInitialized();
+ return $this->teamMemberManager->getMembersFromTeam($this->getEntity(), $singleId);
+ }
+
+ public function addMember(Team|string $team, TeamEntity|string $entity): TeamMember {
+ $this->confirmSessionInitialized();
+
+ if (!($team instanceof Team)) {
+ $team = $this->teamService->getTeam($this->getEntity(), $team);
+ }
+ if (!($entity instanceof TeamEntity)) {
+ $entity = $this->teamEntityService->getTeamEntity($entity);
+ }
+
+ return $this->teamMemberService->addMember($this->getEntity(), $team, $entity);
+ }
+}
diff --git a/lib/Api/v2/Operation/TeamMembershipOperation.php b/lib/Api/v2/Operation/TeamMembershipOperation.php
new file mode 100644
index 000000000..40191fbf9
--- /dev/null
+++ b/lib/Api/v2/Operation/TeamMembershipOperation.php
@@ -0,0 +1,28 @@
+lowPriorityProcess();
+ $this->teamMembershipService->syncTeamMemberships($this->getSession(), $singleId);
+ }
+
+
+}
diff --git a/lib/Api/v2/Operation/TeamOperation.php b/lib/Api/v2/Operation/TeamOperation.php
new file mode 100644
index 000000000..e5e486040
--- /dev/null
+++ b/lib/Api/v2/Operation/TeamOperation.php
@@ -0,0 +1,66 @@
+confirmSessionInitialized();
+
+ // only APP, OCC and SUPER_ADMIN can create a team under the name of someone else
+ if (!in_array($this->getEntity()->getTeamEntityType(), [
+ TeamEntityType::APP,
+ TeamEntityType::OCC,
+ TeamEntityType::SUPER_ADMIN,
+ ],true)) {
+ $realOwner = null;
+ }
+
+ return $this->teamService->create($this->getEntity(), $teamName, $realOwner ?? null);
+ }
+
+ /**
+ * @throws TeamNotFoundException
+ */
+ public function getTeam(string $singleId): Team {
+ $this->confirmSessionInitialized();
+ return $this->teamManager->getTeam($singleId);
+ }
+
+ /**
+ * @return TeamMember[]
+ */
+ public function getTeams(): array {
+ $this->confirmSessionInitialized();
+ return [];
+ }
+
+ public function getAvailableTeams(): array {
+ $this->confirmSessionInitialized();
+ return [];
+ }
+}
diff --git a/lib/Api/v2/Operation/TeamSuperOperation.php b/lib/Api/v2/Operation/TeamSuperOperation.php
new file mode 100644
index 000000000..a26e01843
--- /dev/null
+++ b/lib/Api/v2/Operation/TeamSuperOperation.php
@@ -0,0 +1,47 @@
+lowPriorityProcess([TeamEntityType::SUPER_ADMIN]);
+
+ $this->teamManager->dropAll();
+ $this->teamEntityManager->dropAll();
+ $this->teamMemberManager->dropAll();
+ $this->teamMembershipManager->dropAll();
+
+ $this->userConfig->deleteKey(Application::APP_ID, 'teamSingleId');
+ $this->appConfig->deleteKey(Application::APP_ID, 'occSingleId');
+ $this->appConfig->deleteKey(Application::APP_ID, 'superAdminSingleId');
+ foreach($this->appConfig->searchValues('teamSingleId') as $appId => $value) {
+ $this->appConfig->deleteKey($appId, 'teamSingleId');
+ }
+ }
+}
diff --git a/lib/Api/v2/TeamSession.php b/lib/Api/v2/TeamSession.php
new file mode 100644
index 000000000..61b7cb9a2
--- /dev/null
+++ b/lib/Api/v2/TeamSession.php
@@ -0,0 +1,172 @@
+
+ * @copyright 2017
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Circles\Api\v2;
+
+use OCA\Circles\Api\v2\Operation\TeamEntityOperation;
+use OCA\Circles\Api\v2\Operation\TeamMemberOperation;
+use OCA\Circles\Api\v2\Operation\TeamMembershipOperation;
+use OCA\Circles\Api\v2\Operation\TeamOperation;
+use OCA\Circles\Api\v2\Operation\TeamSuperOperation;
+use OCA\Circles\Model\TeamEntity;
+use OCA\Circles\Service\TeamEntityService;
+use OCP\IUser;
+use OCP\IUserSession;
+
+// API/endpoint/occ => TeamSession -> Operation => Service -> (FederatedItem) -> Manager -> Mapper
+
+class TeamSession implements ITeamSession {
+ private ?TeamEntity $entity = null;
+ private ?ITeamOperation $initiatedTeamOperation = null;
+ private ?ITeamEntityOperation $initiatedTeamEntityOperation = null;
+ private ?ITeamMemberOperation $initiatedTeamMemberOperation = null;
+ private ?ITeamMembershipOperation $initiatedTeamMembershipOperation = null;
+ private ?ITeamSuperOperation $initiatedTeamSuperOperation = null;
+
+ public function __construct(
+ private IUserSession $userSession,
+ private readonly TeamEntityService $teamEntityService,
+ private TeamOperation $teamOperation,
+ private TeamEntityOperation $teamEntityOperation,
+ private TeamMemberOperation $teamMemberOperation,
+ private TeamMembershipOperation $teamMembershipOperation,
+ private TeamSuperOperation $teamSuperOperation,
+ ) {
+ // by default, and if available, ITeamSession uses current user session
+ $user = $this->userSession?->getUser();
+ if ($user !== null) {
+ $this->entity = $this->teamEntityService->generateTeamEntityFromUser($user);
+ } else if (\OC::$CLI) {
+ $this->entity = $this->teamEntityService->generateTeamEntityFromOcc();
+ }
+ }
+
+ public function sessionAsCurrentUser(): self {
+ $user = $this->userSession?->getUser();
+ if ($user === null) {
+ return $this;
+ }
+
+ return $this->sessionAsEntity($this->teamEntityService->generateTeamEntityFromUser($user));
+ }
+
+ public function sessionAsUser(IUser $user): self {
+ return $this->sessionAsEntity($this->teamEntityService->generateTeamEntityFromUser($user));
+ }
+
+ public function sessionAsLocalUser(string $userId): self {
+ return $this->sessionAsEntity($this->teamEntityService->generateTeamEntityFromLocalUser($userId));
+ }
+
+ public function sessionAsApp(string $appId): self {
+ return $this->sessionAsEntity($this->teamEntityService->generateTeamEntityFromApp($appId));
+ }
+
+ public function sessionAsOcc(): self {
+ return $this->sessionAsEntity($this->teamEntityService->generateTeamEntityFromOcc());
+ }
+
+ public function sessionAsSuperAdmin(): self {
+ return $this->sessionAsEntity($this->teamEntityService->generateTeamEntityFromSuperAdmin());
+ }
+
+ public function sessionAsEntity(TeamEntity $entity): self {
+ $session = clone $this;
+ $session->entity = $entity;
+ $this->initiatedTeamOperation = null;
+ $this->initiatedTeamEntityOperation = null;
+ $this->initiatedTeamMemberOperation = null;
+ $this->initiatedTeamMembershipOperation = null;
+ return $session;
+ }
+
+ public function hasEntity(): bool {
+ return ($this->entity !== null);
+ }
+
+ public function getEntity(): TeamEntity {
+ return $this->entity;
+ }
+
+ public function performTeamOperation(): ITeamOperation {
+ if ($this->initiatedTeamOperation === null) {
+ $operation = clone $this->teamOperation;
+ $operation->fromSession($this);
+ $this->initiatedTeamOperation = $operation;
+ }
+
+ return $this->initiatedTeamOperation;
+ }
+
+ public function performTeamEntityOperation(): ITeamEntityOperation {
+ if ($this->initiatedTeamEntityOperation === null) {
+ if ($this->entity === null) {
+ return $this->teamEntityOperation;
+ }
+ $operation = clone $this->teamEntityOperation;
+ $operation->fromSession($this);
+ $this->initiatedTeamEntityOperation = $operation;
+ }
+
+ return $this->initiatedTeamEntityOperation;
+ }
+
+ public function performTeamMemberOperation(): ITeamMemberOperation {
+ if ($this->initiatedTeamMemberOperation === null) {
+ $operation = clone $this->teamMemberOperation;
+ $operation->fromSession($this);
+ $this->initiatedTeamMemberOperation = $operation;
+ }
+
+ return $this->initiatedTeamMemberOperation;
+ }
+
+ public function performTeamMembershipOperation(): ITeamMembershipOperation {
+ if ($this->initiatedTeamMembershipOperation === null) {
+ $operation = clone $this->teamMembershipOperation;
+ $operation->fromSession($this);
+ $this->initiatedTeamMembershipOperation = $operation;
+ }
+
+ return $this->initiatedTeamMembershipOperation;
+ }
+
+ public function performSuperOperation(): ITeamSuperOperation {
+ if ($this->initiatedTeamSuperOperation === null) {
+ $operation = clone $this->teamSuperOperation;
+ $operation->fromSession($this);
+ $this->initiatedTeamSuperOperation = $operation;
+ }
+
+ return $this->initiatedTeamSuperOperation;
+ }
+}
diff --git a/lib/Command/Test.php b/lib/Command/Test.php
new file mode 100644
index 000000000..17a237254
--- /dev/null
+++ b/lib/Command/Test.php
@@ -0,0 +1,146 @@
+setName('teams:test')
+ ->setDescription('create a new circle')
+// ->addArgument('owner', InputArgument::REQUIRED, 'owner of the circle')
+// ->addArgument('name', InputArgument::REQUIRED, 'name of the circle')
+ ->addOption('reset', '', InputOption::VALUE_NONE, 'reset all data');
+// ->addOption('local', '', InputOption::VALUE_NONE, 'create a local circle')
+// ->addOption('status-code', '', InputOption::VALUE_NONE, 'display status code on exception')
+// ->addOption(
+// 'type', '', InputOption::VALUE_REQUIRED, 'type of the owner',
+// Member::$TYPE[Member::TYPE_SINGLE]
+// );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+// $team = $this->teamSession->performTeamOperation()->getTeam('KRyQQW3d5OinYfT');
+
+ if ($input->getOption('reset')) {
+ $session = $this->teamSession->sessionAsSuperAdmin();
+ $session->performSuperOperation()->dropAllData();
+ $output->writeln('reset');
+ return 0;
+ }
+//
+ $entityOperation = $this->teamSession->performTeamEntityOperation();
+ $test3 = $entityOperation->getFromLocalUser('test3');
+
+ $entityOperation = $this->teamSession->performTeamEntityOperation();
+ $team1 = $this->teamSession->performTeamOperation()->createTeam(
+ 'this is a test 001',
+ $entityOperation->getFromLocalUser('test3')
+ );
+ $team2 = $this->teamSession->performTeamOperation()->createTeam(
+ 'this is a test 002',
+ $team1->asEntity()
+ );
+ $team3 = $this->teamSession->performTeamOperation()->createTeam(
+ 'this is a test 003',
+ $team2->asEntity()
+ );
+ $team4 = $this->teamSession->performTeamOperation()->createTeam(
+ 'this is a test 001',
+ $team3->asEntity()
+ );
+//
+// echo json_encode($team1, JSON_PRETTY_PRINT) ."\n";
+// echo json_encode($team2, JSON_PRETTY_PRINT) ."\n";
+// echo json_encode($team3, JSON_PRETTY_PRINT) ."\n";
+// echo json_encode($team4, JSON_PRETTY_PRINT) ."\n";
+
+// $teamOperation = $this->teamSession->performTeamOperation();
+// $teams = $teamOperation->getTeams();
+
+// return 0;
+
+ $this->teamSession->performTeamMemberOperation()->addMember(
+ $team4,
+ $entityOperation->getFromLocalUser('test1')
+ );
+
+
+ echo json_encode($this->teamSession->performTeamOperation()->getTeam($team2->getSingleId()), JSON_PRETTY_PRINT) . "\n";
+ $session = $this->teamSession->sessionAsSuperAdmin();
+ $membershipOperation = $session->performTeamMembershipOperation();
+ $membershipOperation->syncTeamMemberships($team1->getSingleId());
+
+ //echo json_encode($teams, JSON_PRETTY_PRINT) . "\n";
+
+ return 0;
+
+ $ownerId = $input->getArgument('owner');
+ $name = $input->getArgument('name');
+
+ try {
+ $this->federatedUserService->bypassCurrentUserCondition(true);
+
+ $type = Member::parseTypeString($input->getOption('type'));
+
+ $owner = $this->federatedUserService->getFederatedUser($ownerId, $type);
+ $outcome = $this->circleService->create(
+ $name,
+ $owner,
+ $input->getOption('personal'),
+ $input->getOption('local')
+ );
+ } catch (FederatedItemException $e) {
+ if ($input->getOption('status-code')) {
+ throw new FederatedItemException(
+ ' [' . get_class($e) . ', ' . $e->getStatus() . ']' . "\n" . $e->getMessage()
+ );
+ }
+
+ throw $e;
+ }
+
+ if (strtolower($input->getOption('output')) === 'json') {
+ $output->writeln(json_encode($outcome, JSON_PRETTY_PRINT));
+ } elseif (strtolower($input->getOption('output')) !== 'none') {
+ /** @var Circle $circle */
+ $circle = $this->deserialize($outcome, Circle::class);
+ $output->writeln('Id: ' . $circle->getSingleId() . '');
+ $output->writeln('Name: ' . $circle->getDisplayName() . '');
+ $output->writeln('Owner: ' . $circle->getOwner()->getDisplayName() . '');
+ }
+
+ return 0;
+ }
+}
diff --git a/lib/Controller/LocalController.php b/lib/Controller/LocalController.php
index dc8e4a096..fce3d1471 100644
--- a/lib/Controller/LocalController.php
+++ b/lib/Controller/LocalController.php
@@ -423,6 +423,34 @@ public function circles(int $limit = -1, int $offset = 0): DataResponse {
}
+
+ /**
+ * @NoAdminRequired
+ *
+ * @param int $limit
+ * @param int $offset
+ *
+ * @return DataResponse
+ * @throws OCSException
+ */
+ public function currentCircles(int $limit = -1, int $offset = 0): DataResponse {
+ try {
+ $this->setCurrentFederatedUser();
+
+ $probe = new CircleProbe();
+ $probe->filterHiddenCircles()
+ ->filterBackendCircles()
+ ->setItemsLimit($limit)
+ ->setItemsOffset($offset);
+
+ return new DataResponse($this->serializeArray($this->circleService->probeCircles($probe)));
+ } catch (Exception $e) {
+ $this->e($e);
+ throw new OCSException($e->getMessage(), (int)$e->getCode());
+ }
+ }
+
+
/**
* @NoAdminRequired
*
diff --git a/lib/Db/CoreMapper.php b/lib/Db/CoreMapper.php
new file mode 100644
index 000000000..a911cf017
--- /dev/null
+++ b/lib/Db/CoreMapper.php
@@ -0,0 +1,58 @@
+> [class => list] */
+ private array $fields = [];
+
+ /**
+ * returns the list of database table field names from an Entity
+ *
+ * @return string[]
+ */
+ final protected function getFields(string $entityClass): array {
+ if (!array_key_exists($entityClass, $this->fields)) {
+ $entity = new $entityClass();
+ if ($entity instanceof Entity) {
+ $this->fields[$entityClass] = array_map(static function(string $property) use ($entity): string {
+ return $entity->propertyToColumn($property);
+ }, array_keys($entity->getFieldTypes()));
+ } else {
+ Server::get(LoggerInterface::class)->notice('calling CoreMapper::getFields() with a non-Entity model', ['entityClass' => $entityClass]);
+ $this->fields[$entityClass] = [];
+ }
+ }
+
+ return $this->fields[$entityClass];
+ }
+
+ /**
+ * Setting $useMemberships to FALSE will make your request heavier !
+ *
+ * @param TeamEntity|null $initiator
+ * @param IQueryBuilder $qb
+ * @param bool $useMemberships set to FALSE to bypass memberships structured caching, meaning making heavier request
+ */
+ protected function limitToInitiator(?TeamEntity $initiator, IQueryBuilder $qb, bool $useMemberships = true): void {
+ if ($initiator === null) {
+ return;
+ }
+
+ // TODO finish, based on useMemberships
+ }
+}
diff --git a/lib/Db/TeamEntityMapper.php b/lib/Db/TeamEntityMapper.php
new file mode 100644
index 000000000..021a54094
--- /dev/null
+++ b/lib/Db/TeamEntityMapper.php
@@ -0,0 +1,104 @@
+
+ */
+class TeamEntityMapper extends CoreMapper {
+ public const TABLE = 'teams_entities';
+ private array $fields = [];
+
+ public function __construct(
+ IDBConnection $db,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct($db, self::TABLE, TeamEntity::class);
+ }
+
+ public function getBySingleId(string $singleId) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('single_id', $qb->createNamedParameter($singleId)));
+
+ try {
+ return $this->findEntity($qb);
+ } catch (DoesNotExistException) {
+ throw new TeamEntityNotFoundException('no team entity found');
+ }
+ }
+
+ public function getByOrigId(TeamEntityType $type, string $origId) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('single_id', $qb->createNamedParameter($type->value)))
+ ->where($qb->expr()->eq('orig_id', $qb->createNamedParameter($origId)));
+
+ try {
+ return $this->findEntity($qb);
+ } catch (DoesNotExistException) {
+ throw new TeamEntityNotFoundException('no team entity found');
+ }
+ }
+
+ public function joinTeamEntity(
+ IQueryBuilder $qb,
+ string $aliasSingleId,
+ string $fieldSingleId,
+ string $aliasDetails = 'te',
+ bool $leftJoin = false,
+ ): void {
+ foreach ($this->getFields(TeamEntity::class) as $field) {
+ $qb->selectAlias($aliasDetails . '.' . $field, $aliasDetails . '_' . $field);
+ }
+
+ $join = ($leftJoin) ? 'leftJoin' : 'innerJoin';
+ $qb->$join(
+ $aliasSingleId,
+ self::TABLE,
+ $aliasDetails,
+ $qb->expr()->eq($aliasSingleId . '.' . $fieldSingleId, $aliasDetails . '.single_id')
+ );
+ }
+
+ /**
+ * @throws TeamEntityNotFoundException
+ */
+ public function createTeamEntityFromRow(array $row, string $alias = ''): TeamEntity {
+ $new = [];
+ foreach($this->getFields(TeamEntity::class) as $field) {
+ $new[$field] = $row[$alias . $field];
+ }
+
+ if ($new['id'] === null) {
+ throw new TeamEntityNotFoundException();
+ }
+
+ return $this->mapRowToEntity($new);
+ }
+
+ public function emptyTable() {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(self::TABLE);
+ $qb->executeStatement();
+ }
+}
diff --git a/lib/Db/TeamMapper.php b/lib/Db/TeamMapper.php
new file mode 100644
index 000000000..bd9374d7f
--- /dev/null
+++ b/lib/Db/TeamMapper.php
@@ -0,0 +1,93 @@
+
+ */
+class TeamMapper extends CoreMapper {
+ public const TABLE = 'teams';
+ private array $fields = [];
+
+ public function __construct(
+ IDBConnection $db,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct($db, self::TABLE, Team::class);
+ }
+
+ public function getBySingleId(string $singleId): Team {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('single_id', $qb->createNamedParameter($singleId)));
+
+ try {
+ return $this->findEntity($qb);
+ } catch (DoesNotExistException) {
+ throw new TeamNotFoundException('no team found');
+ }
+ }
+
+ public function joinTeam(
+ IQueryBuilder $qb,
+ string $aliasSingleId,
+ string $fieldSingleId,
+ string $aliasDetails = 't',
+ bool $leftJoin = false
+ ): void {
+ foreach ($this->getFields(Team::class) as $field) {
+ $qb->selectAlias($aliasDetails . '.' . $field, $aliasDetails . '_' . $field);
+ }
+
+ $join = ($leftJoin) ? 'leftJoin' : 'innerJoin';
+ $qb->$join(
+ $aliasSingleId,
+ self::TABLE,
+ $aliasDetails,
+ $qb->expr()->eq($aliasSingleId . '.' . $fieldSingleId, $aliasDetails . '.single_id')
+ );
+ }
+
+ /**
+ * @throws TeamNotFoundException
+ */
+ public function createTeamFromRow(array $row, string $alias = ''): Team {
+ $new = [];
+ foreach($this->getFields(Team::class) as $field) {
+ $new[$field] = $row[$alias . $field];
+ }
+
+ if ($new['id'] === null) {
+ throw new TeamNotFoundException();
+ }
+
+ return $this->mapRowToEntity($new);
+ }
+
+ public function emptyTable() {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(self::TABLE);
+ $qb->executeStatement();
+ }
+}
diff --git a/lib/Db/TeamMemberMapper.php b/lib/Db/TeamMemberMapper.php
new file mode 100644
index 000000000..1c84a40cf
--- /dev/null
+++ b/lib/Db/TeamMemberMapper.php
@@ -0,0 +1,92 @@
+
+ */
+class TeamMemberMapper extends CoreMapper {
+ public const TABLE = 'teams_members';
+
+ private array $fields = [];
+
+ public function __construct(
+ IDBConnection $db,
+ private readonly TeamMapper $teamMapper,
+ private readonly TeamEntityMapper $teamEntityMapper,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct($db, self::TABLE, TeamMember::class);
+ }
+
+ /**
+ * @return TeamMember[]
+ */
+ public function getTeamMembers(?TeamEntity $initiator, string $teamSingleId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('tm.*')
+ ->from($this->getTableName(), 'tm')
+ ->where($qb->expr()->eq('team_single_id', $qb->createNamedParameter($teamSingleId)));
+
+ $this->limitToInitiator($initiator, $qb, false);
+ $this->teamEntityMapper->joinTeamEntity($qb, 'tm', 'member_single_id', 'te');
+
+ $result = $qb->executeQuery();
+ $teamMembers = [];
+ while ($row = $result->fetch()) {
+ $teamMember = $this->createTeamMemberFromRow($row);
+ $teamMember->setEntity($this->teamEntityMapper->createTeamEntityFromRow($row, 'te_'));
+ $teamMembers[] = $teamMember;
+ }
+
+ return $teamMembers;
+ }
+
+
+ public function getEntityTeams(?TeamEntity $initiator, string $singleId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('tm.*')
+ ->from($this->getTableName(), 'tm')
+ ->where($qb->expr()->eq('member_single_id', $qb->createNamedParameter($singleId)));
+
+ $this->limitToInitiator($initiator, $qb, false);
+ $this->teamMapper->joinTeam($qb, 'tm', 'team_single_id', 't');
+
+ $result = $qb->executeQuery();
+ $teamMembers = [];
+ while ($row = $result->fetch()) {
+ $teamMember = $this->createTeamMemberFromRow($row);
+ $teamMember->setTeam($this->teamMapper->createTeamFromRow($row, 't_'));
+ $teamMembers[] = $teamMember;
+ }
+
+ return $teamMembers;
+ }
+
+ public function createTeamMemberFromRow(array $row, string $alias = ''): TeamMember {
+ $new = [];
+ foreach($this->getFields(TeamMember::class) as $field) {
+ $new[$field] = $row[$alias . $field];
+ }
+
+ return $this->mapRowToEntity($new);
+ }
+
+ public function emptyTable() {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(self::TABLE);
+ $qb->executeStatement();
+ }
+}
diff --git a/lib/Db/TeamMembershipMapper.php b/lib/Db/TeamMembershipMapper.php
new file mode 100644
index 000000000..13aacf796
--- /dev/null
+++ b/lib/Db/TeamMembershipMapper.php
@@ -0,0 +1,71 @@
+
+ */
+class TeamMembershipMapper extends QBMapper {
+ public const TABLE = 'teams_memberships';
+
+ private array $fields = [];
+
+ public function __construct(
+ IDBConnection $db,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct($db, self::TABLE, TeamMembership::class);
+ }
+
+ public function getMembershipsRelatedToEntity(string $singleId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('single_id', $qb->createNamedParameter($singleId)));
+
+ return $this->findEntities($qb);
+ }
+
+ public function getByTeamAndEntity(string $teamSingleId, string $singleId): TeamMembership {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('single_id', $qb->createNamedParameter($singleId)))
+ ->andWhere($qb->expr()->eq('team_single_id', $qb->createNamedParameter($teamSingleId)));
+
+ try {
+ return $this->findEntity($qb);
+ } catch (DoesNotExistException) {
+ throw new TeamMembershipNotFoundException('no team membership found');
+ }
+ }
+
+ public function removeMembership(string $teamSingleId, string $singleId): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->getTableName())
+ ->where($qb->expr()->eq('single_id', $qb->createNamedParameter($singleId)))
+ ->andWhere($qb->expr()->eq('team_single_id', $qb->createNamedParameter($teamSingleId)));
+
+ $qb->executeStatement();
+ }
+
+ public function emptyTable() {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(self::TABLE);
+ $qb->executeStatement();
+ }
+}
diff --git a/lib/Enum/TeamApi.php b/lib/Enum/TeamApi.php
new file mode 100644
index 000000000..9cfc35c95
--- /dev/null
+++ b/lib/Enum/TeamApi.php
@@ -0,0 +1,15 @@
+getTeam();
+
+ $event->setOutcome($team->jsonSerialize());
+ }
+
+ /**
+ * @param FederatedEvent $event
+ *
+ * @throws FederatedEventDSyncException
+ * @throws InvalidIdException
+ * @throws RequestBuilderException
+ */
+ public function manage(FederatedEvent $event): void {
+ $team = $event->getTeam();
+ $owner = $team->getOwner();
+ //
+ //
+ //
+ //
+ //
+ try {
+ $this->teamManager->getTeam($team->getSingleId());
+ throw new FederatedEventDSyncException('Circle already exist');
+ } catch (CircleNotFoundException $e) {
+ }
+
+ $this->teamManager->confirmNaming($team);
+
+ try {
+ $this->teamEntityManager->getTeamEntity($owner->getMemberSingleId());
+ throw new FederatedEventDSyncException('Owner already exist');
+ } catch (MemberNotFoundException $e) {
+ }
+
+ if ($owner->hasInvitedBy()) {
+ $owner->setNoteObj('invitedBy', $owner->getInvitedBy());
+ }
+
+ $this->circleRequest->save($circle);
+ $this->memberRequest->save($owner);
+
+ $this->membershipService->onUpdate($owner->getSingleId());
+ $this->membershipService->updatePopulation($circle);
+
+ $this->eventService->circleCreating($event);
+ }
+
+ /**
+ * @param FederatedEvent $event
+ * @param array $results
+ */
+ public function result(FederatedEvent $event, array $results): void {
+ $this->eventService->circleCreated($event, $results);
+ }
+}
diff --git a/lib/Managers/CoreManager.php b/lib/Managers/CoreManager.php
new file mode 100644
index 000000000..e2aac1656
--- /dev/null
+++ b/lib/Managers/CoreManager.php
@@ -0,0 +1,64 @@
+getEntity();
+ }
+
+ return $initiator;
+ }
+
+ /**
+ * returns NULL if initiator have super permissions (occ, super_admin)
+ */
+ final protected function filterInitiator(TeamSession|TeamEntity $initiator): ?TeamEntity {
+ $initiator = $this->extractTeamEntity($initiator);
+
+ if (in_array($initiator->getTeamEntityType(), [
+ TeamEntityType::OCC,
+ TeamEntityType::SUPER_ADMIN,
+ ], true)) {
+ return null;
+ }
+
+ return $initiator;
+ }
+
+ /**
+ * @throws TeamEntityOverwritePermissionException
+ * @throws TeamMembershipNotFoundException
+ */
+ final protected function getInitiatorMembership(TeamEntity|TeamSession $initiator, string $teamSingleId): TeamMembership {
+ $initiatorEntity = $this->filterInitiator($initiator);
+ if ($initiatorEntity === null) {
+ throw new TeamEntityOverwritePermissionException('initiator is above everyone, bypassing membership check');
+ }
+
+ return Server::get(TeamMembershipMapper::class)->getByTeamAndEntity($teamSingleId, $initiatorEntity->getSingleId());
+ }
+
+}
diff --git a/lib/Managers/TeamEntityManager.php b/lib/Managers/TeamEntityManager.php
new file mode 100644
index 000000000..30e6ae84a
--- /dev/null
+++ b/lib/Managers/TeamEntityManager.php
@@ -0,0 +1,92 @@
+
+ * @copyright 2017
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Circles\Managers;
+
+use OCA\Circles\Db\TeamEntityMapper;
+use OCA\Circles\Enum\TeamEntityType;
+use OCA\Circles\Exceptions\TeamEntityNotFoundException;
+use OCA\Circles\Model\TeamEntity;
+use OCA\Circles\Service\ToolsService;
+
+class TeamEntityManager {
+ /** @var TeamEntity[] */
+ private array $entities = [];
+
+ public function __construct(
+ private readonly ToolsService $toolsService,
+ private readonly TeamEntityMapper $teamEntityMapper,
+ ) {
+ }
+
+ /**
+ * @throws TeamEntityNotFoundException
+ */
+ public function getTeamEntity(string $singleId): TeamEntity {
+ if (!array_key_exists($singleId, $this->entities)) {
+ $this->entities[$singleId] = $this->teamEntityMapper->getBySingleId($singleId);
+ }
+
+ return $this->entities[$singleId];
+ }
+
+ /**
+ * @throws TeamEntityNotFoundException
+ */
+ public function searchTeamEntity(TeamEntityType $type, string $origId): TeamEntity {
+ return $this->teamEntityMapper->getByOrigId($type, $origId);
+ }
+
+ public function createTeamEntity(
+ TeamEntityType $type,
+ string $origId,
+ string $displayName,
+ ?string $singleId = null
+ ): TeamEntity {
+ $teamEntity = new TeamEntity();
+ $teamEntity->setSingleId($singleId ?? $this->toolsService->generateSingleId());
+ $teamEntity->setTeamEntityType($type);
+ $teamEntity->setOrigId($origId);
+ $teamEntity->setDisplayName($displayName);
+ $this->teamEntityMapper->insert($teamEntity);
+
+ return $teamEntity;
+ }
+
+ public function save(TeamEntity $teamEntity): void {
+ // TODO: do not store teamentity if type === TEAM
+ }
+
+ public function dropAll() {
+ $this->teamEntityMapper->emptyTable();
+ }
+}
diff --git a/lib/Managers/TeamManager.php b/lib/Managers/TeamManager.php
new file mode 100644
index 000000000..c57e5f23b
--- /dev/null
+++ b/lib/Managers/TeamManager.php
@@ -0,0 +1,81 @@
+
+ * @copyright 2017
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Circles\Managers;
+
+
+use OCA\Circles\Db\TeamMapper;
+use OCA\Circles\Exceptions\TeamNotFoundException;
+use OCA\Circles\Model\Circle;
+use OCA\Circles\Model\Team;
+
+class TeamManager {
+ public function __construct(
+ private TeamMapper $teamMapper,
+ ) {
+ }
+
+ /**
+ * @throws TeamNotFoundException
+ */
+ public function getTeam(string $singleId): Team {
+ return $this->teamMapper->getBySingleId($singleId);
+ }
+
+ public function confirmNaming(Team $team): void {
+ if ($team->isConfig(Circle::CFG_SYSTEM)
+ || $team->isConfig(Circle::CFG_SINGLE)) {
+ return;
+ }
+
+// $this->confirmDisplayName($circle);
+// $this->generateSanitizedName($circle);
+ }
+
+// public function createTeam(
+// TeamEntityType $type,
+// string $origId,
+// string $displayName,
+// ): TeamEntity {
+// $teamEntity = new TeamEntity();
+// $teamEntity->setTeamEntityType($type);
+// $teamEntity->setOrigId($origId);
+// $teamEntity->setDisplayName($displayName);
+// $this->teamEntityMapper->insert($teamEntity);
+//
+// return $teamEntity;
+// }
+
+ public function dropAll() {
+ $this->teamMapper->emptyTable();
+ }
+}
diff --git a/lib/Managers/TeamMemberManager.php b/lib/Managers/TeamMemberManager.php
new file mode 100644
index 000000000..d909bbb3c
--- /dev/null
+++ b/lib/Managers/TeamMemberManager.php
@@ -0,0 +1,104 @@
+
+ * @copyright 2017
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Circles\Managers;
+
+use OCA\Circles\Api\v2\TeamSession;
+use OCA\Circles\Db\TeamEntityMapper;
+use OCA\Circles\Db\TeamMemberMapper;
+use OCA\Circles\Db\TeamMembershipMapper;
+use OCA\Circles\Enum\TeamEntityType;
+use OCA\Circles\Enum\TeamMemberLevel;
+use OCA\Circles\Exceptions\TeamEntityNotFoundException;
+use OCA\Circles\Exceptions\TeamEntityOverwritePermissionException;
+use OCA\Circles\Exceptions\TeamMembershipNotFoundException;
+use OCA\Circles\Model\TeamEntity;
+use OCA\Circles\Model\TeamMember;
+use OCA\Circles\Model\TeamMembership;
+use OCA\Circles\Service\ToolsService;
+
+class TeamMemberManager extends CoreManager {
+ public function __construct(
+ private readonly ToolsService $toolsService,
+ private readonly TeamMemberMapper $teamMemberMapper,
+ private readonly TeamMembershipMapper $teamMembershipMapper,
+ ) {
+ }
+
+ public function getTeamMember(TeamSession|TeamEntity $initiator, string $teamSingleId, string $memberSingleId): TeamMember {
+ // return $this->teamMemberMapper->getByTeamAndMember($teamSingleId, $memberSingleId);
+ }
+
+
+ public function addMember(TeamSession|TeamEntity $initiator, string $teamSingleId, string $singleId): TeamMember {
+ try {
+ $initiatorMembership = $this->getInitiatorMembership($initiator, $teamSingleId);
+ if ($initiatorMembership->getLevel() < TeamMemberLevel::MODERATOR->value) {
+ throw new \Exception('not enough permissions'); // TODO: real exception
+ }
+ } catch (TeamEntityOverwritePermissionException) {
+ }
+
+ $teamMember = new TeamMember();
+ $teamMember->setTeamSingleId($teamSingleId);
+ $teamMember->setMemberSingleId($singleId);
+ $teamMember->setTeamMemberLevel(TeamMemberLevel::MEMBER);
+ $teamMember->setInvitedBy($this->extractTeamEntity($initiator));
+ $this->teamMemberMapper->insert($teamMember);
+
+ return $teamMember;
+ }
+
+ /**
+ * @return TeamMember[]
+ */
+ public function getMembersFromTeam(TeamSession|TeamEntity $initiator, string $teamSingleId): array {
+ return $this->teamMemberMapper->getTeamMembers(
+ $this->filterInitiator($initiator),
+ $teamSingleId
+ );
+ }
+
+ /**
+ * @return TeamMember[]
+ */
+ public function getTeamsContainingEntity(TeamSession|TeamEntity $initiator, $singleId) {
+ return $this->teamMemberMapper->getEntityTeams(
+ $this->filterInitiator($initiator),
+ $singleId
+ );
+ }
+
+ public function dropAll() {
+ $this->teamMemberMapper->emptyTable();
+ }
+}
diff --git a/lib/Managers/TeamMembershipManager.php b/lib/Managers/TeamMembershipManager.php
new file mode 100644
index 000000000..c9b7f8dd2
--- /dev/null
+++ b/lib/Managers/TeamMembershipManager.php
@@ -0,0 +1,81 @@
+
+ * @copyright 2017
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Circles\Managers;
+
+use OCA\Circles\Db\TeamEntityMapper;
+use OCA\Circles\Db\TeamMemberMapper;
+use OCA\Circles\Db\TeamMembershipMapper;
+use OCA\Circles\Enum\TeamEntityType;
+use OCA\Circles\Exceptions\TeamEntityNotFoundException;
+use OCA\Circles\Model\TeamEntity;
+use OCA\Circles\Model\TeamMember;
+use OCA\Circles\Model\TeamMembership;
+use OCA\Circles\Service\ToolsService;
+
+class TeamMembershipManager {
+ public function __construct(
+ private readonly ToolsService $toolsService,
+ private readonly TeamMembershipMapper $teamMembershipMapper,
+ ) {
+ }
+
+ /**
+ * @return TeamMembership[]
+ */
+ public function getMemberships(string $singleId): array {
+ return $this->teamMembershipMapper->getMembershipsRelatedToEntity($singleId);
+ }
+
+ public function insertMembership(TeamMembership $teamMembership): void {
+ $this->teamMembershipMapper->insert($teamMembership);
+ }
+
+ public function overwriteMembership(int $id, TeamMembership $teamMembership): void {
+ $teamMembership->setId($id);
+ $this->teamMembershipMapper->update($teamMembership);
+ }
+
+ public function removeMembership(TeamMembership $teamMembership): void {
+ $this->teamMembershipMapper->removeMembership($teamMembership->getSingleId(), $teamMembership->getTeamSingleId());
+ }
+
+ public function removeSingleId(string $singleId): void {
+ //$this->teamMembershipMapper->remove
+ }
+
+ public function dropAll() {
+ $this->teamMembershipMapper->emptyTable();
+ }
+
+
+}
diff --git a/lib/Migration/Version3200Date20250115140201.php b/lib/Migration/Version3200Date20250115140201.php
new file mode 100644
index 000000000..486c46abe
--- /dev/null
+++ b/lib/Migration/Version3200Date20250115140201.php
@@ -0,0 +1,273 @@
+hasTable('teams')) {
+ $table = $schema->createTable('teams');
+ $table->addColumn(
+ 'id', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 4,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'single_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]
+ );
+ $table->addColumn(
+ 'display_name', Types::STRING, [
+ 'notnull' => false,
+ 'default' => '',
+ 'length' => 255
+ ]
+ );
+ $table->addColumn(
+ 'sanitized_name', Types::STRING, [
+ 'notnull' => false,
+ 'default' => '',
+ 'length' => 127
+ ]
+ );
+ $table->addColumn(
+ 'config', Types::INTEGER, [
+ 'notnull' => false,
+ 'length' => 11,
+ 'default' => 0,
+ 'unsigned' => true
+ ]
+ );
+ $table->addColumn(
+ 'settings', Types::TEXT, [
+ 'notnull' => false,
+ 'default' => '[]',
+ ]
+ );
+ $table->addColumn(
+ 'metadata', Types::TEXT, [
+ 'notnull' => false,
+ 'default' => '[]',
+ ]
+ );
+ $table->addColumn(
+ 'creation', Types::INTEGER, [
+ 'notnull' => false,
+ 'default' => 0,
+ ]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['single_id']);
+ $table->addIndex(['display_name']);
+ $table->addUniqueIndex(['sanitized_name']);
+ $table->addIndex(['config']);
+ $table->addIndex(['creation']);
+
+ $modified = true;
+ }
+
+ if (!$schema->hasTable('teams_entities')) {
+ $table = $schema->createTable('teams_entities');
+ $table->addColumn(
+ 'id', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 4,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'single_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]
+ );
+ $table->addColumn(
+ 'type', Types::INTEGER, [
+ 'notnull' => false,
+ 'length' => 4,
+ 'default' => 0,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'orig_id', Types::STRING, [
+ 'notnull' => false,
+ 'default' => '',
+ 'length' => 127,
+ ]
+ );
+ $table->addColumn(
+ 'display_name', Types::STRING, [
+ 'notnull' => false,
+ 'default' => '',
+ 'length' => 255
+ ]
+ );
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['single_id']);
+ $table->addUniqueIndex(['type', 'orig_id']);
+
+ $modified = true;
+ }
+
+ if (!$schema->hasTable('teams_members')) {
+ $table = $schema->createTable('teams_members');
+ $table->addColumn(
+ 'id', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 4,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'team_single_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]
+ );
+ $table->addColumn(
+ 'member_single_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]
+ );
+ $table->addColumn(
+ 'invited_by_single_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]
+ );
+ $table->addColumn(
+ 'level', Types::INTEGER, [
+ 'notnull' => false,
+ 'default' => 0,
+ 'length' => 2
+ ]
+ );
+ $table->addColumn(
+ 'metadata', Types::TEXT, [
+ 'notnull' => false,
+ 'default' => '[]',
+ ]
+ );
+ $table->addColumn(
+ 'creation', Types::INTEGER, [
+ 'notnull' => false,
+ 'default' => 0,
+ ]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['team_single_id', 'member_single_id']);
+ $table->addIndex(['level', 'creation']);
+
+ $modified = true;
+ }
+
+ if (!$schema->hasTable('teams_memberships')) {
+ $table = $schema->createTable('teams_memberships');
+ $table->addColumn(
+ 'id', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 4,
+ 'unsigned' => true,
+ ]
+ );
+ $table->addColumn(
+ 'single_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]
+ );
+ $table->addColumn(
+ 'team_single_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ ]
+ );
+ //inheritance_path
+ $table->addColumn(
+ 'level', Types::INTEGER, [
+ 'notnull' => false,
+ 'default' => 0,
+ 'length' => 2
+ ]
+ );
+ $table->addColumn(
+ 'inheritance_first', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ 'default' => '',
+ ]
+ );
+ $table->addColumn(
+ 'inheritance_last', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 31,
+ 'default' => '',
+ ]
+ );
+ $table->addColumn(
+ 'inheritance_depth', Types::INTEGER, [
+ 'notnull' => true,
+ 'length' => 2,
+ 'unsigned' => true,
+ 'default' => 0,
+ ]
+ );
+ $table->addColumn(
+ 'inheritance_path', Types::TEXT, [
+ 'notnull' => true,
+ 'default' => '[]',
+ ]
+ );
+
+ $table->setPrimaryKey(['id']);
+ $table->addUniqueIndex(['single_id', 'team_single_id']);
+ $table->addUniqueIndex(['inheritance_first', 'inheritance_last', 'team_single_id'], 'teams_mbs_iit');
+ $table->addIndex(['single_id']);
+ $table->addIndex(['team_single_id']);
+
+ $modified = true;
+ }
+
+
+
+ if (!$modified) {
+ return null;
+ }
+
+ return $schema;
+ }
+}
diff --git a/lib/Model/Federated/FederatedEvent.php b/lib/Model/Federated/FederatedEvent.php
index 0a3370613..8f492c62f 100644
--- a/lib/Model/Federated/FederatedEvent.php
+++ b/lib/Model/Federated/FederatedEvent.php
@@ -12,8 +12,10 @@
namespace OCA\Circles\Model\Federated;
use JsonSerializable;
+use OCA\Circles\Exceptions\TeamNotFoundException;
use OCA\Circles\Model\Circle;
use OCA\Circles\Model\Member;
+use OCA\Circles\Model\Team;
use OCA\Circles\Tools\Exceptions\InvalidItemException;
use OCA\Circles\Tools\Model\SimpleDataStore;
use OCA\Circles\Tools\Traits\TArrayTools;
@@ -96,12 +98,8 @@ class FederatedEvent implements JsonSerializable {
private bool $forceSync = false;
+ private ?Team $team = null;
- /**
- * FederatedEvent constructor.
- *
- * @param string $class
- */
public function __construct(string $class = '') {
$this->class = $class;
$this->params = new SimpleDataStore();
@@ -275,6 +273,22 @@ public function getCircle(): Circle {
}
+
+ public function setTeam(Team $team): self {
+ $this->team = $team;
+
+ return $this;
+ }
+
+ public function getTeam(): Team {
+ if ($this->team === null) {
+ throw new TeamNotFoundException('Team not found in FederatedEvent');
+ }
+
+ return $this->team;
+ }
+
+
/**
* @param string $itemId
*
diff --git a/lib/Model/Team.php b/lib/Model/Team.php
new file mode 100644
index 000000000..7db4b96db
--- /dev/null
+++ b/lib/Model/Team.php
@@ -0,0 +1,140 @@
+addType('singleId', 'string');
+ $this->addType('displayName', 'string');
+ $this->addType('sanitizedName', 'string');
+ $this->addType('config', 'integer');
+ $this->addType('settings', 'json');
+ $this->addType('metadata', 'json');
+ $this->addType('creation', 'integer');
+ }
+
+ public function getOwner(): TeamEntity {
+ if ($this->owner === null) {
+ $owner = new TeamEntity();
+ $owner->import($this->metadata['_owner'] ?? []);
+ if ($owner->isValid()) {
+ $this->owner = $owner;
+ }
+ }
+
+ if ($this->owner === null) {
+ throw new TeamOwnerNotFoundException();
+ }
+
+ return $this->owner;
+ }
+
+ public function setOwner(TeamEntity $owner): void {
+ $this->owner = $owner;
+ $this->setMetaValue('_owner', $owner->jsonSerialize());
+ }
+
+ /**
+ * only returns metadata that does not start with underscore '_'
+ */
+ public function getFilteredMetadata(): array {
+ return array_filter($this->metadata, static fn(string $k): bool => !str_starts_with($k, '_'), ARRAY_FILTER_USE_KEY);
+ }
+
+ public function setMetaValue(string $key, string|int|float|bool|array $value): void {
+ $metadata = $this->metadata;
+ $metadata[$key] = $value;
+ $this->setter('metadata', [$metadata]);
+ }
+
+ public function getMembers(null|TeamSession|TeamEntity $initiator = null): ?array {
+ $initiator ??= $this->lazyLoadingEntity;
+ if ($initiator === null) {
+ return null;
+ }
+
+ return $this->getTeamMemberManager()->getMembersFromTeam($initiator, $this->getSingleId());
+ }
+
+ public function asEntity(): TeamEntity {
+ return new TeamEntity($this);
+ }
+
+ /**
+ * @return array
+ */
+ public function jsonSerialize(): array {
+ if ($this->isApiVersion(TeamApi::V2)) {
+ return [
+ 'singleId' => $this->getSingleId(),
+ 'displayName' => $this->getDisplayName(),
+ 'sanitizedName' => $this->getSanitizedName(),
+ 'config' => $this->getConfig(),
+ 'settings' => $this->getSettings(),
+ 'metadata' => $this->getFilteredMetadata(),
+ 'members' => $this->getMembers(),
+ 'creation' => $this->getCreation(),
+ 'owner' => $this->metadata['_owner'] ?? []
+ ];
+ }
+
+ if ($this->isApiVersion(TeamApi::V1)) {
+ return [];
+ }
+
+ return [
+ 'singleId' => $this->getSingleId(),
+ 'displayName' => $this->getDisplayName(),
+ 'sanitizedName' => $this->getSanitizedName(),
+ 'config' => $this->getConfig(),
+ 'settings' => $this->getSettings(),
+ 'metadata' => $this->getMetadata(),
+ 'members' => $this->getMembers(),
+ 'creation' => $this->getCreation(),
+ 'owner' => $this->metadata['_owner'] ?? []
+ ];
+ }
+}
diff --git a/lib/Model/TeamCore.php b/lib/Model/TeamCore.php
new file mode 100644
index 000000000..a601a39ac
--- /dev/null
+++ b/lib/Model/TeamCore.php
@@ -0,0 +1,59 @@
+lazyLoadingEntity = $entity;
+ }
+
+ public function getTeamEntityManager(): ?TeamEntityManager {
+ if ($this->teamEntityManager === null) {
+ $this->teamEntityManager = Server::get(TeamEntityManager::class);
+ }
+
+ return $this->teamEntityManager;
+ }
+
+ public function getTeamMemberManager(): TeamMemberManager {
+ if ($this->teamMemberManager === null) {
+ $this->teamMemberManager = Server::get(TeamMemberManager::class);
+ }
+
+ return $this->teamMemberManager;
+ }
+
+ protected function isApiVersion(TeamApi $api): bool {
+ return ($api === $this->getApiVersion());
+ }
+
+ protected function getApiVersion(): TeamApi {
+ if (!defined('USING_TEAMS_API')) {
+ return self::DEFAULT_TEAMS_API;
+ }
+
+ return USING_TEAMS_API;
+ }
+}
diff --git a/lib/Model/TeamEntity.php b/lib/Model/TeamEntity.php
new file mode 100644
index 000000000..d7ed7e088
--- /dev/null
+++ b/lib/Model/TeamEntity.php
@@ -0,0 +1,114 @@
+addType('singleId', 'string');
+ $this->addType('type', 'integer');
+ $this->addType('origId', 'string');
+ $this->addType('displayName', 'string');
+
+ if ($orig === null) {
+ return;
+ }
+
+ if ($orig instanceof Team) {
+ $this->setSingleId($orig->getSingleId());
+ $this->setTeamEntityType(TeamEntityType::TEAM);
+ $this->setOrigId($orig->getSingleId());
+ $this->setDisplayName($orig->getDisplayName());
+ }
+ }
+
+ public function getTeamEntityType(): TeamEntityType {
+ return TeamEntityType::from($this->getType());
+ }
+
+ public function setTeamEntityType(TeamEntityType $type): void {
+ $this->setType($type->value);
+ }
+
+ public function getDisplayName(): string {
+ if ($this->displayName === '' && $this->singleId !== '') {
+ try {
+ $lazyTeamEntity = $this->getTeamEntityManager()?->getTeamEntity($this->singleId);
+ $this->displayName = $lazyTeamEntity?->getDisplayName() ?? '';
+ } catch (TeamEntityNotFoundException) {
+ }
+ }
+
+ return $this->displayName;
+ }
+
+ public function isTeam(): bool {
+ return ($this->getTeamEntityType() === TeamEntityType::TEAM);
+ }
+
+ public function asTeam(): Team {
+ // TODO: generate a team from current entity;
+ }
+
+ public function import(array $data): void {
+ $this->setSingleId($data['singleId']);
+ $this->setType($data['type']);
+ $this->setOrigId($data['origId']);
+ $this->setDisplayName($data['displayName']);
+ }
+
+ public function isValid(): bool {
+ return true; // TODO
+ }
+
+ /**
+ * @return array
+ */
+ public function jsonSerialize(bool $ignoreApi = false): array {
+
+ if (!$ignoreApi && $this->isApiVersion(TeamApi::V2)) {
+ return [
+ 'singleId' => $this->getSingleId(),
+ 'displayName' => $this->getDisplayName(),
+ 'type' => $this->getType(),
+ ];
+ }
+
+ if (!$ignoreApi && $this->isApiVersion(TeamAPI::V1)) {
+ return []; // TODO: old API compat
+ }
+
+ return [
+ 'singleId' => $this->getSingleId(),
+ 'type' => $this->getType(),
+ 'origId' => $this->getOrigId(),
+ 'displayName' => $this->getDisplayName(),
+ ];
+ }
+}
diff --git a/lib/Model/TeamMember.php b/lib/Model/TeamMember.php
new file mode 100644
index 000000000..b7b6e7c42
--- /dev/null
+++ b/lib/Model/TeamMember.php
@@ -0,0 +1,163 @@
+addType('teamSingleId', 'string');
+ $this->addType('memberSingleId', 'string');
+ $this->addType('level', 'integer');
+ $this->addType('invitedBySingleId', 'string');
+ $this->addType('metadata', 'json');
+ $this->addType('creation', 'integer');
+ }
+
+ public function getTeamMemberLevel(): TeamMemberLevel {
+ return TeamMemberLevel::from($this->getLevel());
+ }
+
+ public function setTeamMemberLevel(TeamMemberLevel $level): void {
+ $this->setLevel($level->value);
+ }
+
+ public function getTeam(): ?Team {
+ // TODO: team can be null, build from meta (needed ?)
+ return $this->team;
+ }
+
+ public function setTeam(Team $team): void {
+ $this->team = $team;
+ $this->setTeamSingleId($team->getSingleId());
+ }
+
+ public function getEntity(): ?TeamEntity {
+ // TODO: member can be null, build from meta (needed ?)
+ return $this->entity;
+ }
+
+ public function setEntity(TeamEntity $entity): void {
+ $this->entity = $entity;
+ $this->setMemberSingleId($entity->getSingleId());
+ }
+
+ /**
+ * @throws TeamEntityNotFoundException
+ */
+ public function getInvitedBy(): TeamEntity {
+ if ($this->invitedBy === null) {
+ $invitedBy = new TeamEntity();
+ $invitedBy->import($this->metadata['_invitedBy'] ?? []);
+ if ($invitedBy->isValid()) {
+ $this->invitedBy = $invitedBy;
+ }
+ }
+
+ if ($this->invitedBy === null) {
+ throw new TeamEntityNotFoundException();
+ }
+
+ return $this->invitedBy;
+ }
+
+ public function setInvitedBy(TeamEntity $invitedBy): void {
+ $this->invitedBy = $invitedBy;
+ $this->setInvitedBySingleId($invitedBy->getSingleId());
+ $this->setMetaValue('_invitedBy', $invitedBy->jsonSerialize(true));
+ }
+
+ public function setMetaValue(string $key, string|int|float|bool|array $value): void {
+ $metadata = $this->metadata;
+ $metadata[$key] = $value;
+ $this->setter('metadata', [$metadata]);
+ }
+
+ /**
+ * only returns metadata that does not start with underscore '_'
+ */
+ public function getFilteredMetadata(): array {
+ return array_filter($this->metadata, static fn(string $k): bool => !str_starts_with($k, '_'), ARRAY_FILTER_USE_KEY);
+ }
+
+ public function import(array $data): void {
+ $this->setTeamSingleId($data['teamSingleId'] ?? '');
+ $this->setMemberSingleId($data['memberSingleId'] ?? '');
+ $this->setLevel($data['level'] = 0);
+ $this->setMetadata($data['metadata'] ?? []);
+ $this->setCreation($data['creation'] ?? 0);
+ }
+
+ public function isValid(): bool {
+ return ($this->getTeamSingleId() !== '' && $this->getMemberSingleId() !== '');
+ }
+
+ /**
+ * @return array
+ */
+ public function jsonSerialize(): array {
+ if ($this->isApiVersion(TeamApi::V2)) {
+ return [
+ 'teamId' => $this->getTeamSingleId(),
+ 'singleId' => $this->getMemberSingleId(),
+ 'displayName' => $this->getEntity()?->getDisplayName() ?? '',
+ 'level' => $this->getLevel(),
+ 'metadata' => $this->getFilteredMetadata(),
+ 'invitedBy' => $this->getInvitedBy(),
+ 'creation' => $this->getCreation()
+ ];
+ }
+
+ if ($this->isApiVersion(TeamAPI::V1)) {
+ return []; // TODO: old API compat
+ }
+
+ return [
+ 'teamSingleId' => $this->getTeamSingleId(),
+ 'memberSingleId' => $this->getMemberSingleId(),
+ 'team' => $this->getTeam(),
+ 'member' => $this->getEntity(),
+ 'level' => $this->getLevel(),
+ 'metadata' => $this->getMetadata(),
+ 'creation' => $this->getCreation(),
+ 'invitedBy' => $this->metadata['_invitedBy'] ?? [],
+ ];
+ }
+}
diff --git a/lib/Model/TeamMembership.php b/lib/Model/TeamMembership.php
new file mode 100644
index 000000000..b5194c5e7
--- /dev/null
+++ b/lib/Model/TeamMembership.php
@@ -0,0 +1,99 @@
+addType('singleId', 'string');
+ $this->addType('teamSingleId', 'string');
+ $this->addType('level', 'integer');
+ $this->addType('inheritanceFirst', 'string');
+ $this->addType('inheritanceLast', 'string');
+ $this->addType('inheritanceDepth', 'integer');
+ $this->addType('inheritancePath', 'json');
+ }
+
+ public function setPath(array $path): void {
+ array_shift($path);
+ $path = array_reverse($path);
+ $this->setInheritancePath($path);
+
+ $depth = count($path);
+ $this->setInheritanceDepth($depth);
+ if ($depth === 0) {
+ return;
+ }
+
+ $this->setInheritanceFirst($path[0]);
+ $this->setInheritanceLast(array_pop($path));
+ }
+
+ /**
+ * @return array
+ */
+ public function jsonSerialize(): array {
+ if ($this->isApiVersion(TeamApi::V2)) {
+ return [
+ 'singleId' => $this->getSingleId(),
+ 'teamSingleId' => $this->getTeamSingleId(),
+ 'level' => $this->getLevel(),
+ 'inheritanceFirst' => $this->getInheritanceFirst(),
+ 'inheritanceLast' => $this->getInheritanceLast(),
+ 'inheritanceDepth' => $this->getInheritanceDepth(),
+ 'inheritancePath' => $this->getInheritancePath()
+ ];
+ }
+
+ if ($this->isApiVersion(TeamAPI::V1)) {
+ return []; // TODO: old API compat
+ }
+
+ return [
+ 'singleId' => $this->getSingleId(),
+ 'teamSingleId' => $this->getTeamSingleId(),
+ 'level' => $this->getLevel(),
+ 'inheritanceFirst' => $this->getInheritanceFirst(),
+ 'inheritanceLast' => $this->getInheritanceLast(),
+ 'inheritanceDepth' => $this->getInheritanceDepth(),
+ 'inheritancePath' => $this->getInheritancePath()
+ ];
+ }
+}
diff --git a/lib/Service/TeamEntityService.php b/lib/Service/TeamEntityService.php
new file mode 100644
index 000000000..eb9566bf2
--- /dev/null
+++ b/lib/Service/TeamEntityService.php
@@ -0,0 +1,132 @@
+teamEntityManager->getTeamEntity($singleId);
+ }
+
+ public function generateTeamEntityFromLocalUser(string $userId): TeamEntity {
+ $singleId = $this->userConfig->getValueString($userId, Application::APP_ID, 'teamSingleId');
+ if ($singleId !== '') {
+ return $this->generateTeamEntity($singleId, TeamEntityType::LOCAL_USER, $userId);
+ }
+
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ throw new TeamEntityNotFoundException('local user not found');
+ }
+ return $this->generateTeamEntityFromUser($user);
+ }
+
+ /**
+ * @throws \OCA\Circles\Exceptions\TeamNotFoundException
+ */
+ public function generateTeamEntityFromTeam(string $singleId): TeamEntity {
+ return $this->teamManager->getTeam($singleId)->asEntity();
+ }
+
+ public function generateTeamEntityFromApp(string $appId): TeamEntity {
+ $singleId = $this->appConfig->getValueString($appId, 'teamSingleId');
+ if ($singleId !== '') {
+ return $this->generateTeamEntity($singleId, TeamEntityType::APP, $appId, $appId);
+ }
+
+ try {
+ $teamEntity = $this->teamEntityManager->searchTeamEntity(TeamEntityType::APP, $appId);
+ } catch (TeamEntityNotFoundException) {
+ $teamEntity = $this->teamEntityManager->createTeamEntity(TeamEntityType::APP, $appId, $appId);
+ }
+ $this->appConfig->setValueString($appId, 'teamSingleId', $teamEntity->getSingleId());
+
+ return $teamEntity;
+ }
+
+ public function generateTeamEntityFromOcc(): TeamEntity {
+ $singleId = $this->appConfig->getValueString(Application::APP_ID, 'occSingleId');
+ if ($singleId !== '') {
+ return $this->generateTeamEntity($singleId, TeamEntityType::OCC, 'occ', 'Occ Command');
+ }
+
+ try {
+ $teamEntity = $this->teamEntityManager->searchTeamEntity(TeamEntityType::OCC, 'occ');
+ } catch (TeamEntityNotFoundException) {
+ $teamEntity = $this->teamEntityManager->createTeamEntity(TeamEntityType::OCC, 'occ', 'Occ Command');
+ }
+ $this->appConfig->setValueString(Application::APP_ID, 'occSingleId', $teamEntity->getSingleId());
+
+ return $teamEntity;
+ }
+
+ public function generateTeamEntityFromSuperAdmin(): TeamEntity {
+ $singleId = $this->appConfig->getValueString(Application::APP_ID, 'superAdminSingleId');
+ if ($singleId !== '') {
+ return $this->generateTeamEntity($singleId, TeamEntityType::SUPER_ADMIN, 'superAdmin', 'Super Admin Script');
+ }
+
+ try {
+ $teamEntity = $this->teamEntityManager->searchTeamEntity(TeamEntityType::SUPER_ADMIN, 'superAdmin');
+ } catch (TeamEntityNotFoundException) {
+ $teamEntity = $this->teamEntityManager->createTeamEntity(TeamEntityType::SUPER_ADMIN, 'superAdmin', 'Super Admin Script');
+ }
+ $this->appConfig->setValueString(Application::APP_ID, 'superAdminSingleId', $teamEntity->getSingleId());
+
+ return $teamEntity;
+ }
+
+ public function generateTeamEntityFromUser(IUser $user): TeamEntity {
+ $singleId = $this->userConfig->getValueString($user->getUID(), Application::APP_ID, 'teamSingleId');
+ if ($singleId === '') {
+ try {
+ $teamEntity = $this->teamEntityManager->searchTeamEntity(TeamEntityType::LOCAL_USER, $user->getUID());
+ } catch (TeamEntityNotFoundException) {
+ $teamEntity = $this->teamEntityManager->createTeamEntity(TeamEntityType::LOCAL_USER, $user->getUID(), $user->getDisplayName());
+ }
+ $this->userConfig->setValueString($user->getUID(), Application::APP_ID, 'teamSingleId', $teamEntity->getSingleId());
+ return $teamEntity;
+ }
+
+ return $this->generateTeamEntity($singleId, TeamEntityType::LOCAL_USER, $user->getUID(), $user->getDisplayName());
+ }
+
+ private function generateTeamEntity(string $singleId, TeamEntityType $type, string $origId, string $displayName = ''): TeamEntity {
+ $entity = new TeamEntity();
+ $entity->setSingleId($singleId);
+ $entity->setTeamEntityType($type);
+ $entity->setOrigId($origId);
+ $entity->setDisplayName($displayName);
+ return $entity;
+ }
+
+}
diff --git a/lib/Service/TeamMemberService.php b/lib/Service/TeamMemberService.php
new file mode 100644
index 000000000..c4c6ba1ab
--- /dev/null
+++ b/lib/Service/TeamMemberService.php
@@ -0,0 +1,37 @@
+teamMemberManager->addMember($initiator, $team->getSingleId(), $entity->getSingleId());
+ }
+
+}
diff --git a/lib/Service/TeamMembershipService.php b/lib/Service/TeamMembershipService.php
new file mode 100644
index 000000000..302444984
--- /dev/null
+++ b/lib/Service/TeamMembershipService.php
@@ -0,0 +1,405 @@
+getEntity()->getTeamEntityType() !== TeamEntityType::SUPER_ADMIN) {
+ throw new \Exception('must be ran as super admin'); // TODO: distinct exception
+ }
+
+ if ($singleId === '') {
+ return;
+ }
+
+ try {
+ $this->teamEntityManager->getTeamEntity($singleId);
+ } catch (TeamEntityNotFoundException) {
+ $this->teamMembershipManager->removeSingleId($singleId);
+ }
+
+ foreach ($this->getDescendantsSingleIds($teamSession, $singleId) as $entry) {
+ $this->manageMemberships($teamSession, $entry);
+ }
+ }
+
+ /**
+ * recursively obtains the list of all singleIds descendant of one singleId.
+ *
+ * @return list
+ */
+ private function getDescendantsSingleIds(TeamSession $session, string $singleId): array {
+ $singleIds = [$singleId];
+ $members = $this->teamMemberManager->getMembersFromTeam($session, $singleId);
+ foreach ($members as $member) {
+ if ($member->getTeamMemberLevel() === TeamMemberLevel::INVITED) {
+ continue; // only real members
+ }
+ $singleIds[] = $member->getMemberSingleId();
+ if ($member->getEntity()?->getTeamEntityType() === TeamEntityType::TEAM) {
+ $this->teamMemberManager->getMembersFromTeam($session, $member->getMemberSingleId());
+ }
+ }
+
+ return array_unique($singleIds);
+ }
+
+ /**
+ * @return int number of modified rows from the database
+ */
+ private function manageMemberships(TeamSession $session, string $singleId): int {
+ /** @var array $memberships */
+ $memberships = [];
+ $this->generateMemberships($session, $singleId, memberships: $memberships);
+ return $this->updateMembershipsDatabase($singleId, $memberships);
+ }
+
+// /**
+// * @param string $circleId
+// * @param string $singleId
+// * @param bool $detailed
+// *
+// * @return Membership
+// * @throws MembershipNotFoundException
+// * @throws RequestBuilderException
+// */
+// public function getMembership(string $circleId, string $singleId, bool $detailed = false): Membership {
+// $membership = $this->membershipRequest->getMembership($circleId, $singleId);
+// if ($detailed) {
+// $details = $this->circleRequest->getCirclesByIds($membership->getInheritancePath());
+// $membership->setInheritanceDetails($details);
+// }
+//
+// return $membership;
+// }
+//
+// /**
+// * @param string $singleId
+// * @param bool $all
+// */
+// public function resetMemberships(string $singleId = '', bool $all = false) {
+// $this->membershipRequest->removeBySingleId($singleId, $all);
+// }
+
+ /**
+ * @param FederatedUser $federatedUser
+ */
+ public function deleteFederatedUser(FederatedUser $federatedUser) {
+ $this->membershipRequest->deleteFederatedUser($federatedUser);
+ }
+
+ /**
+ * Return the list of freshly generated Memberships from a singleId
+ * directly based on oc_teams_members
+ *
+ * @param list $path
+ * @param array $memberships
+ */
+ private function generateMemberships(
+ TeamSession $session,
+ string $singleId,
+ string $teamId = '',
+ array $path = [],
+ array &$memberships = [],
+ ): void {
+ $teamId = ($teamId === '') ? $singleId : $teamId;
+ $path[] = $teamId;
+
+ $members = $this->teamMemberManager->getTeamsContainingEntity($session, $teamId);
+ foreach ($members as $member) {
+ if ($member->getTeamMemberLevel() === TeamMemberLevel::INVITED) {
+ continue; // we do not care about invited people
+ }
+
+ $membership = new TeamMembership();
+ $membership->setSingleId($singleId);
+ $membership->setTeamSingleId($member->getTeamSingleId());
+ $membership->setLevel($member->getLevel());
+ $membership->setPath($path);
+
+ /**
+ * if we already have a memberships between entity and team, we check the depth of inheritance
+ * and the final level assigned to the membership.
+ * The idea is to:
+ * - avoid duplicate memberships
+ * - keep the membership with higher level
+ * - keep the membership with the shortest inheritance path
+ */
+ $known = $memberships[$member->getTeamSingleId()];
+ if ($known === null
+ || $known->getLevel() < $membership->getLevel()
+ || ($known->getLevel() === $membership->getLevel()
+ && $known->getInheritanceDepth() > $membership->getInheritanceDepth())) {
+ $memberships[$member->getTeamSingleId()] = $membership;
+ }
+
+ $this->generateMemberships(
+ $session,
+ $singleId,
+ $member->getTeamSingleId(),
+ $path,
+ $memberships,
+ );
+ }
+ }
+
+ /**
+ * @param array $memberships
+ *
+ * @return int number of entries from the database modified during the process
+ */
+ private function updateMembershipsDatabase(string $singleId, array $memberships): int {
+ $known = $this->teamMembershipManager->getMemberships($singleId);
+
+ $indexed = [];
+ foreach($known as $entry) {
+ $indexed[$entry->getTeamSingleId()] = $entry;
+ }
+
+ $deprecated = $this->removeDeprecatedMemberships($memberships, $indexed);
+
+
+// if (!empty($deprecated)) {
+// $this->eventService->membershipsRemoved($deprecated);
+// }
+//
+ $updated = $this->updateMemberships($memberships, $indexed);
+ $new = $this->createMemberships($memberships, $indexed);
+
+ // if (!empty($new)) {
+// $this->eventService->membershipsCreated($new);
+// }
+
+ // TODO: implement and confirm feature
+ // for each entries in the arrays ?
+ // // clearing the getSharedWith() cache for singleId related to the membership
+ // $this->shareWrapperService->clearCache($membership->getSingleId());
+
+ return count($deprecated) + count($new) + count($updated);
+ }
+
+ /**
+ * @param string $id
+ * @param array $knownIds
+ *
+ * @return array
+ * @throws RequestBuilderException
+ */
+ private function getChildrenMembers( string $id, array &$knownIds = []): array {
+ $singleIds = array_map(
+ function(Member $item): string {
+ return $item->getSingleId();
+ }, $this->memberRequest->getMembers($id)
+ );
+
+ foreach ($singleIds as $singleId) {
+ if ($singleId !== $id && !in_array($singleId, $knownIds)) {
+ $knownIds[] = $singleId;
+ $singleIds = array_merge($singleIds, $this->getChildrenMembers($singleId, $knownIds));
+ }
+ }
+
+ return array_unique($singleIds);
+ }
+
+ /**
+ * @param string $id
+ * @param array $knownIds
+ *
+ * @return array
+ */
+ private
+ function getChildrenMemberships(
+ string $id, array &$knownIds = []
+ ): array {
+ $singleIds = array_map(
+ function(Membership $item): string {
+ return $item->getSingleId();
+ }, $this->membershipRequest->getInherited($id)
+ );
+
+ foreach ($singleIds as $singleId) {
+ if ($singleId !== $id && !in_array($singleId, $knownIds)) {
+ $knownIds[] = $singleId;
+ $singleIds = array_merge($singleIds, $this->getChildrenMemberships($singleId, $knownIds));
+ }
+ }
+
+ return array_unique($singleIds);
+ }
+
+ /**
+ * Add the new membership if unknown or Update known membership if:
+ * - new membership comes with more power
+ * - level is the same, but inheritance is shorter
+ *
+ * @param Membership $membership
+ * @param Membership[] $memberships
+ */
+ private function fillMemberships(
+ Membership $membership, array &$memberships
+ ) {
+ foreach ($memberships as $known) {
+ if ($known->getCircleId() === $membership->getCircleId()) {
+ if ($known->getLevel() < $membership->getLevel()
+ || ($known->getLevel() === $membership->getLevel()
+ && $known->getInheritanceDepth() > $membership->getInheritanceDepth())
+ ) {
+ $known->setLevel($membership->getLevel());
+ $known->setInheritanceFirst($membership->getInheritanceFirst());
+ $known->setInheritanceLast($membership->getInheritanceLast());
+ $known->setInheritanceDepth($membership->getInheritanceDepth());
+ $known->setInheritancePath($membership->getInheritancePath());
+ }
+
+ return;
+ }
+ }
+
+ $memberships[$membership->getCircleId()] = $membership;
+ }
+
+ /**
+ * @param list $memberships
+ * @param list $known
+ *
+ * @return TeamMembership[]
+ */
+ private function removeDeprecatedMemberships(array $memberships, array $known): array {
+ $teamSingleIds = array_keys($memberships);
+
+ $deprecated = [];
+ foreach ($known as $singleId => $item) {
+ if (!in_array($singleId, $teamSingleIds, true)) {
+ $deprecated[] = $item;
+ $this->teamMembershipManager->removeMembership($item);
+ }
+ }
+
+ return $deprecated;
+ }
+
+ /**
+ * @param list $memberships
+ * @param list $known
+ *
+ * @return TeamMembership[]
+ */
+ private function updateMemberships(array $memberships, array $known): array {
+ $updated = [];
+
+ foreach ($memberships as $singleId => $membership) {
+ if (!array_key_exists($singleId, $known)) {
+ continue;
+ }
+
+ $item = $known[$singleId];
+ if ($item->getLevel() !== $membership->getLevel()
+ || $item->getInheritanceDepth() !== $membership->getInheritanceDepth()) {
+ $this->teamMembershipManager->overwriteMembership($item->getId(), $membership);
+ $updated[] = $membership;
+ } elseif ($item->getInheritancePath() !== $membership->getInheritancePath()) {
+ $this->teamMembershipManager->overwriteMembership($item->getId(), $membership);
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * @param list $memberships
+ * @param list $known
+ *
+ * @return TeamMembership[]
+ */
+ private function createMemberships(array $memberships, array $known): array {
+ $new = [];
+
+ foreach ($memberships as $singleId => $membership) {
+ if (array_key_exists($singleId, $known)) {
+ continue;
+ }
+ $this->teamMembershipManager->insertMembership($membership);
+ $new[] = $membership;
+ }
+
+ return $new;
+ }
+
+ /**
+ * @param Membership[] $list
+ * @param string $circleId
+ *
+ * @return Membership
+ * @throws ItemNotFoundException
+ */
+ private
+ function getMembershipsFromList(
+ array $list, string $circleId
+ ): Membership {
+ foreach ($list as $item) {
+ if ($item->getCircleId() === $circleId) {
+ return $item;
+ }
+ }
+
+ throw new ItemNotFoundException();
+ }
+
+ /**
+ * @param Circle $circle
+ */
+ public
+ function updatePopulation(
+ Circle $circle
+ ): void {
+ $local = $inherited = 0;
+ $memberships = $this->membershipRequest->getInherited($circle->getSingleId(), Member::LEVEL_MEMBER);
+ foreach ($memberships as $membership) {
+ $inherited++;
+ if ($membership->getCircleId() === $circle->getSingleId()) {
+ $local++;
+ }
+ }
+
+ $settings = $circle->getSettings();
+ $settings['population'] = $local;
+ $settings['populationInherited'] = $inherited;
+ $this->circleRequest->updateSettings($circle->setSettings($settings));
+ }
+}
diff --git a/lib/Service/TeamService.php b/lib/Service/TeamService.php
new file mode 100644
index 000000000..cc7356017
--- /dev/null
+++ b/lib/Service/TeamService.php
@@ -0,0 +1,90 @@
+toolsService->generateSingleId();
+
+ // todo: check team_single_id does not exist in oc_teams not oc_teams_members
+
+// $owner = new TeamMember();
+// $owner->setTeamSingleId($teamSingleId);
+// $owner->setMemberSingleId($ownerSingleId);
+// $owner->setTeamMemberLevel(TeamMemberLevel::OWNER);
+// $owner->setCreation(time());
+// $this->teamMemberMapper->insert($owner);
+
+// $owner = $this->teamEntityManager->getTeamEntity($ownerSingleId);
+//
+// $owner = new TeamEntity();
+// $owner->setTeamEntityType(TeamEntityType::LOCAL_USER);
+// $owner->setDisplayName('test_test_owner');
+// $owner->setSingleId('asde');
+// $owner->setOrigId('admin');
+// $owner->enableLazyLoading();
+// $realOwner?->enableLazyLoading();
+
+ $team = new Team();
+ $team->enableLazyloading($owner);
+ $team->setSingleId($teamSingleId);
+ $team->setDisplayName($name);
+ $team->setSanitizedName($this->toolsService->generateSingleId(6)); // TODO
+ $team->setOwner($realOwner ?? $owner);
+ $this->teamMapper->insert($team);
+
+ $member = new TeamMember();
+ $member->setInvitedBy($owner);
+ $member->setTeamMemberLevel(TeamMemberLevel::OWNER);
+ $member->setTeam($team);
+ $member->setEntity($team->getOwner());
+ $this->teamMemberMapper->insert($member);
+
+ $this->teamEntityManager->createTeamEntity(
+ TeamEntityType::TEAM,
+ $team->getSingleId(),
+ $team->getDisplayName(),
+ $team->getSingleId()
+ );
+
+// $team->setMembers([$member]);
+
+// $mem
+
+ return $team;
+ }
+
+ public function getTeam(TeamEntity $initiator, string $teamSingleId): Team {
+ $this->teamMapper->getBySingleId($teamSingleId);
+ }
+
+ private function generateMetadata(Team $team): void {
+ }
+}
diff --git a/lib/Service/ToolsService.php b/lib/Service/ToolsService.php
new file mode 100644
index 000000000..879001843
--- /dev/null
+++ b/lib/Service/ToolsService.php
@@ -0,0 +1,27 @@
+