Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,10 @@ Vrij en open source onder de EUPL-licentie.
shared `contractNumber`: a wrong cross-app link is worse than an
empty one. -->
<command>OCA\Stackiq\Command\ReferencesAuditCommand</command>
<!-- Link an organisatie to an existing OpenRegister organisation. The
organisation sync leaves an organisatie unlinked when another
organisation already has its name; only an administrator links them. -->
<command>OCA\Stackiq\Command\OrganisationLinkCommand</command>
</commands>

<settings>
Expand Down
111 changes: 111 additions & 0 deletions lib/Command/OrganisationLinkCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/**
* Stackiq Organisation Link Command
*
* Links an organisatie to an existing OpenRegister organisation, as an
* administrator decides.
*
* @category Command
* @package OCA\Stackiq\Command
*
* @author Conduction Development Team <[email protected]>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @link https://conduction.nl
*
* SPDX-FileCopyrightText: 2026 Conduction B.V. <[email protected]>
* SPDX-License-Identifier: EUPL-1.2
*/

declare(strict_types=1);

namespace OCA\Stackiq\Command;

use OCA\Stackiq\Service\OrganizationSyncService;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;

/**
* `occ stackiq:organisation:link <organisatie-uuid> <organisation-uuid>`.
*
* The organisation sync never takes over an OpenRegister organisation whose
* name gives the same slug: it leaves the organisatie unlinked and logs it.
* This command is how an administrator links the two when they are the same.
*
* @spec openspec/specs/organisatie-service/spec.md
*
* phpcs:disable CustomSniffs.Functions.NamedParameters
*/
class OrganisationLinkCommand extends Command {

/**
* What each outcome means, for the console.
*/
private const MESSAGES = [
'linked' => '<info>Linked.</info> The organisation now carries the organisatie uuid and owns the organisatie.',
'no-organisatie' => '<error>No organisatie has this uuid.</error>',
'already-linked' => '<error>This organisatie already has an OpenRegister organisation.</error>',
'no-organisation' => '<error>No OpenRegister organisation has this uuid.</error>',
'organisation-in-use' => '<error>This OpenRegister organisation belongs to another organisatie.</error>',
];

/**
* Build the command.
*
* @param ContainerInterface $container Container, for the lazy sync service resolve.
*/
public function __construct(
private readonly ContainerInterface $container,
) {
parent::__construct();
}//end __construct()

/**
* Declare the command.
*
* @return void
*
* @spec openspec/specs/organisatie-service/spec.md
*/
protected function configure(): void {
$this->setName('stackiq:organisation:link')
->setDescription('Link an organisatie to an existing OpenRegister organisation.')
->addArgument('organisatie', InputArgument::REQUIRED, 'The organisatie object uuid.')
->addArgument('organisation', InputArgument::REQUIRED, 'The OpenRegister organisation uuid.');
}//end configure()

/**
* Make the link, or say why not.
*
* @param InputInterface $input Console input.
* @param OutputInterface $output Console output.
*
* @return int 0 when linked, 1 otherwise.
*
* @spec openspec/specs/organisatie-service/spec.md
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$outcome = $this->container->get(OrganizationSyncService::class)->linkOrganisationEntity(
organisatieUuid: (string) $input->getArgument('organisatie'),
entityUuid: (string) $input->getArgument('organisation')
);
} catch (Throwable $e) {
$output->writeln(messages: '<error>Could not link: ' . $e->getMessage() . '</error>');
return 1;
}

$output->writeln(messages: self::MESSAGES[$outcome] ?? $outcome);
if ($outcome === 'linked') {
return 0;
}

return 1;
}//end execute()
}//end class
13 changes: 13 additions & 0 deletions lib/Service/OrganisatieService.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
* @SuppressWarnings(PHPMD.UnusedPrivateMethod)
*/
class OrganisatieService {
/**
* OpenRegister's exception for a taken slug, by name (OrganisationSlug::CONFLICT_EXCEPTION;
* not imported, to keep this class's coupling down).
*/
private const SLUG_CONFLICT = 'OCA\\OpenRegister\\Exception\\OrganisationSlugConflictException';

/**
* OrganisatieService constructor.
*
Expand Down Expand Up @@ -71,6 +77,8 @@ public function __construct(
*
* @return object|null The created organisation entity or null on failure
*
* OpenRegister's OrganisationSlugConflictException (another organisation has the name's slug) is passed on.
*
* @spec openspec/specs/organisatie-service/spec.md
*/
public function createOrganisationInOpenRegister(array $objectData): ?object {
Expand Down Expand Up @@ -118,6 +126,11 @@ public function createOrganisationInOpenRegister(array $objectData): ?object {

return $organisationEntity;
} catch (\Exception $e) {
// The caller decides what a taken slug means; every other failure ends here.
if (is_a($e, self::SLUG_CONFLICT) === true) {
throw $e;
}

$this->logger->error(
'OrganisatieService: Error creating organization entity',
[
Expand Down
56 changes: 56 additions & 0 deletions lib/Service/OrganisationSlug.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/**
* The slug OpenRegister gives an organisation name.
*
* @category Service
* @package OCA\Stackiq\Service
*
* @author Conduction Development Team <[email protected]>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @link https://conduction.nl
*
* SPDX-FileCopyrightText: 2026 Conduction B.V. <[email protected]>
* SPDX-License-Identifier: EUPL-1.2
*/

declare(strict_types=1);

namespace OCA\Stackiq\Service;

/**
* Mirrors OpenRegister's OrganisationService::generateSlug(): lower case, runs of
* other characters as one hyphen, no hyphen at either end, at most 100 characters.
* It also names OpenRegister's exception for a taken slug.
*
* @spec openspec/specs/organisatie-service/spec.md
*/
class OrganisationSlug {

/**
* OpenRegister's exception for a new organisation whose slug is taken (openregister#5462).
* Named as a string: older OpenRegister releases do not have the class.
*/
public const CONFLICT_EXCEPTION = 'OCA\\OpenRegister\\Exception\\OrganisationSlugConflictException';

/**
* OpenRegister keeps the first 100 characters of a slug.
*/
private const MAX_LENGTH = 100;

/**
* The slug of an organisation name.
*
* @param string $name The organisation name.
*
* @return string The slug.
*
* @spec openspec/specs/organisatie-service/spec.md
*/
public function fromName(string $name): string {
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-');
return substr($slug, 0, self::MAX_LENGTH);
}//end fromName()
}//end class
Loading
Loading