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>
<!-- Back-fill the GEMMA standard links an import before the link fix
saved without. Writes only link properties that differ; a second run
writes nothing. dry-run reports without writing. -->
Expand Down
4 changes: 0 additions & 4 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,9 @@ private function registerDomainServices(IRegistrationContext $context): void {
OrganisatieService::class,
function ($container) {
return new OrganisatieService(
organizationHandler: $container->get(
OrganizationHandler::class
),
logger: $container->get('Psr\Log\LoggerInterface'),
container: $container,
appManager: $container->get('OCP\App\IAppManager'),
config: $container->get(IAppConfig::class),
userManager: $container->get(IUserManager::class),
emailService: $container->get(SymfonyEmailService::class),
);
Expand Down
158 changes: 158 additions & 0 deletions lib/Command/OrganisationLinkCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?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 <info@conduction.nl>
* @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. <info@conduction.nl>
* 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\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
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.
* The organisation takes the organisatie's uuid, so the command first says what
* keeps the old uuid and asks for confirmation (or --yes).
*
* @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>',
'default-organisation' => '<error>This is the OpenRegister default organisation, which is never linked.</error>',
'not-confirmed' => '<comment>Not linked.</comment> Confirm, or run again with --yes.',
];

/**
* What still holds the organisation's old uuid after a link.
*/
private const STORED_REFERENCES = [
'objects in OpenRegister stamped with this organisation (their @self.organisation)',
'the active organisation of its members',
'the parent of its child organisations',
'references in other apps',
];

/**
* 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.')
->addOption('yes', 'y', InputOption::VALUE_NONE, 'Link without asking for confirmation.');
}//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 {
$organisatie = (string) $input->getArgument('organisatie');
$organisation = (string) $input->getArgument('organisation');
try {
$sync = $this->container->get(OrganizationSyncService::class);
$outcome = $sync->checkOrganisationLink(organisatieUuid: $organisatie, entityUuid: $organisation);
if ($outcome === null && $this->confirmed(input: $input, output: $output) === false) {
$outcome = 'not-confirmed';
}

if ($outcome === null) {
$outcome = $sync->linkOrganisationEntity(organisatieUuid: $organisatie, entityUuid: $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()

/**
* Say what keeps the organisation's old uuid, and ask whether to link anyway.
*
* @param InputInterface $input Console input.
* @param OutputInterface $output Console output.
*
* @return bool True when --yes is given or the administrator confirms.
*/
private function confirmed(InputInterface $input, OutputInterface $output): bool {
$organisation = (string) $input->getArgument('organisation');
$organisatie = (string) $input->getArgument('organisatie');
$output->writeln(messages: '<comment>The OpenRegister organisation ' . $organisation . ' will take the uuid ' . $organisatie . '.</comment>');
$output->writeln(messages: 'These keep the old uuid and are not moved:');
foreach (self::STORED_REFERENCES as $reference) {
$output->writeln(messages: ' - ' . $reference);
}

if ($input->getOption('yes') === true) {
return true;
}

return (new QuestionHelper())->ask($input, $output, new ConfirmationQuestion('Link anyway? [y/N] ', false)) === true;
}//end confirmed()
}//end class
32 changes: 16 additions & 16 deletions lib/Service/OrganisatieService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@

namespace OCA\Stackiq\Service;

use OCA\Stackiq\Service\Stackiq\OrganizationHandler;
use OCP\App\IAppManager;
use OCP\IAppConfig;
use OCP\IUserManager;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
Expand All @@ -40,25 +38,29 @@
* @link https://github.com/ConductionNL/stackiq
*
* @SuppressWarnings(PHPMD.UnusedPrivateMethod)
*
* @spec openspec/specs/organisatie-service/spec.md
*/
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.
*
* @param OrganizationHandler $organizationHandler Organization handler
* @param LoggerInterface $logger Logger interface
* @param ContainerInterface $container Container interface
* @param IAppManager $appManager App manager
* @param IAppConfig $config Configuration service
* @param IUserManager $userManager User manager service
* @param SymfonyEmailService $emailService Email service
*/
public function __construct(
private readonly OrganizationHandler $organizationHandler,
private readonly LoggerInterface $logger,
private readonly ContainerInterface $container,
private readonly IAppManager $appManager,
private readonly IAppConfig $config,
private readonly IUserManager $userManager,
private readonly SymfonyEmailService $emailService,
) {
Expand All @@ -71,6 +73,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 +122,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 Expand Up @@ -209,17 +218,8 @@ private function getOrganisationService(): ?\OCA\OpenRegister\Service\Organisati
* @spec openspec/specs/organisatie-service/spec.md
*/
private function mapOrganizationDataForOpenRegister(array $objectData): array {
// Get the organization name - try 'name' first, then 'name', then use UUID as fallback.
$name = $objectData['name'] ?? null;

// If still no name, create a unique one using the ID to avoid slug conflicts.
if (empty($name) === true || $name === 'Unknown') {
$orgId = $objectData['id'] ?? uniqid(prefix: 'org-');
$name = 'Organisation ' . substr($orgId, 0, 8);
}

return [
'name' => $name,
'name' => (new OrganisationSlug())->nameFor(objectData: $objectData),
'type' => $objectData['type'] ?? '',
'website' => $objectData['website'] ?? '',
'active' => $this->mapStatus(status: $objectData['status'] ?? $objectData['beoordeling'] ?? 'actief'),
Expand Down
92 changes: 92 additions & 0 deletions lib/Service/OrganisationSlug.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

/**
* The slug OpenRegister gives an organisation name.
*
* @category Service
* @package OCA\Stackiq\Service
*
* @author Conduction Development Team <info@conduction.nl>
* @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. <info@conduction.nl>
* 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
* (cut after the trim, so a hyphen at position 100 stays). It also gives the name
* stackiq sends OpenRegister for an organisatie.
* 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()

/**
* The name OpenRegister receives for an organisatie.
*
* An empty, "Unknown" or "0" name is replaced by "Organisation " and the first
* 8 characters of the organisatie id, so the slug to check is that name's.
*
* @param array $objectData The organisatie object data.
*
* @return string The organisation name.
*
* @spec openspec/specs/organisatie-service/spec.md
*/
public function nameFor(array $objectData): string {
$name = ($objectData['name'] ?? null);
if (empty($name) === true || $name === 'Unknown') {
return 'Organisation ' . substr((string) ($objectData['id'] ?? ''), 0, 8);
}

return (string) $name;
}//end nameFor()

/**
* The slug OpenRegister gives the organisation of an organisatie.
*
* @param array $objectData The organisatie object data.
*
* @return string The slug.
*
* @spec openspec/specs/organisatie-service/spec.md
*/
public function fromObjectData(array $objectData): string {
return $this->fromName(name: $this->nameFor(objectData: $objectData));
}//end fromObjectData()
}//end class
Loading
Loading