$vexPairs The parsed VEX cveId/bom-ref pairs.
- * @param bool $trackProgress Whether a progress operation is active.
+ * @param array $target `register` (the voorzieningen register id) and `schema`
+ * (the sbomComponent schema id).
+ * @param array $import `moduleVersieUuid` (the target moduleVersie), `components`
+ * (the parsed component DTOs), `vexPairs` (the parsed VEX
+ * cveId/bom-ref pairs) and `trackProgress` (whether a progress
+ * operation is active).
*
* @return int The number of components created.
*
* @spec openspec/specs/sbom-import/spec.md#requirement-large-imports-run-in-bounded-batches-with-progress-reporting
*/
- private function createComponentSet(
- ObjectServiceInterface $objectService,
- int $registerId,
- int $componentSchemaId,
- string $moduleVersieUuid,
- array $components,
- array $vexPairs,
- bool $trackProgress,
- ): int {
+ private function createComponentSet(ObjectServiceInterface $objectService, array $target, array $import): int {
$created = 0;
- $vexCveIdsByRef = $this->groupVexCveIdsByBomRef(vexPairs: $vexPairs);
+ $vexCveIdsByRef = $this->groupVexCveIdsByBomRef(vexPairs: $import['vexPairs']);
- foreach (array_chunk($components, self::BATCH_SIZE) as $batch) {
+ foreach (array_chunk($import['components'], self::BATCH_SIZE) as $batch) {
$payload = [];
foreach ($batch as $component) {
$payload[] = $this->componentToObjectData(
component: $component,
- moduleVersieUuid: $moduleVersieUuid,
+ moduleVersieUuid: $import['moduleVersieUuid'],
vexCveIdsByRef: $vexCveIdsByRef
);
}
$objectService->saveObjects(
objects: $payload,
- register: $registerId,
- schema: $componentSchemaId,
+ register: $target['register'],
+ schema: $target['schema'],
_rbac: false,
_multitenancy: false
);
$created += count($batch);
- if ($trackProgress === true) {
+ if ($import['trackProgress'] === true) {
$this->progressTracker->updateProgress(processedItems: $created);
$this->progressTracker->updateStatistics(['componentsCreated' => $created]);
}
@@ -438,11 +439,11 @@ private function createComponentSet(
* nulled by `saveObject()`).
*
* @param ObjectServiceInterface $objectService The OR object service.
- * @param int $registerId The voorzieningen register id.
- * @param int $versionSchemaId The moduleVersie schema id.
- * @param object $moduleVersion The current moduleVersie entity.
- * @param string $format The import format.
- * @param string $fileName The uploaded file's original name.
+ * @param array $target `register` (the voorzieningen register id) and `schema`
+ * (the moduleVersie schema id).
+ * @param object $moduleVersion The current moduleVersie entity.
+ * @param array $file `format` (the import format) and `fileName` (the uploaded
+ * file's original name).
*
* @return void
*
@@ -450,21 +451,19 @@ private function createComponentSet(
*/
private function recordProvenance(
ObjectServiceInterface $objectService,
- int $registerId,
- int $versionSchemaId,
+ array $target,
object $moduleVersion,
- string $format,
- string $fileName,
+ array $file,
): void {
$data = $moduleVersion->getObject();
$data['sbomLastImportedAt'] = (new DateTime())->format(DateTime::ATOM);
- $data['sbomFormat'] = $format;
- $data['sbomFileName'] = $fileName;
+ $data['sbomFormat'] = $file['format'];
+ $data['sbomFileName'] = $file['fileName'];
$objectService->saveObject(
object: $data,
- register: $registerId,
- schema: $versionSchemaId,
+ register: $target['register'],
+ schema: $target['schema'],
uuid: $moduleVersion->getUuid(),
_rbac: false,
_multitenancy: false
diff --git a/lib/Service/Settings/EmailTemplateDefaults.php b/lib/Service/Settings/EmailTemplateDefaults.php
new file mode 100644
index 000000000..f2412abc3
--- /dev/null
+++ b/lib/Service/Settings/EmailTemplateDefaults.php
@@ -0,0 +1,120 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Settings;
+
+/**
+ * The shipped default e-mail templates and their variables.
+ *
+ * @category Service
+ * @package OCA\Stackiq\Service\Settings
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+final class EmailTemplateDefaults {
+
+ /**
+ * The default content of a template, empty for an unknown template.
+ *
+ * @param string $templateName The template name.
+ *
+ * @return string
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function template(string $templateName): string {
+ $templates = [
+ 'organization_registration' => '
+Welkom bij de Software Catalogus!
+Beste {{ organization.name }},
+Hartelijk welkom bij de Software Catalogus! Uw organisatie is succesvol geregistreerd.
+Met de Software Catalogus kunt u:
+
+ - Uw software overzichtelijk beheren
+ - Software delen met andere organisaties
+ - Ontdekken welke software andere organisaties gebruiken
+
+Uw organisatie heeft de status "{{ organization.beoordeling }}" en kan nu gebruik maken van alle functionaliteiten.
+Heeft u vragen? Neem dan contact met ons op.
+Met vriendelijke groet,
Het Software Catalogus Team
+ ',
+ 'organization_activation' => '
+Uw organisatie is geactiveerd!
+Beste {{ organization.name }},
+Goed nieuws! Uw organisatie is zojuist geactiveerd in de Software Catalogus.
+Dit betekent dat u nu volledig kunt deelnemen aan:
+
+ - Het delen van software informatie
+ - Samenwerking met andere organisaties
+ - Toegang tot alle catalogus functionaliteiten
+
+Status: {{ organization.beoordeling }}
+U kunt nu inloggen en gebruik maken van alle beschikbare functionaliteiten.
+Met vriendelijke groet,
Het Software Catalogus Team
+ ',
+ 'user_creation' => '
+Welkom {{ user.name }}!
+Beste {{ user.name }},
+Er is een gebruikersaccount voor u aangemaakt in de Software Catalogus.
+Uw accountgegevens:
+
+ - E-mailadres: {{ user.email }}
+ - Gebruikersnaam: {{ user.username }}
+ - Organisatie: {{ user.organization.name if user.organization }}
+
+U kunt nu inloggen op het platform en gebruik maken van alle functionaliteiten.
+Heeft u vragen over uw account? Neem dan contact met ons op.
+Met vriendelijke groet,
Het Software Catalogus Team
+ ',
+ ];
+
+ return $templates[$templateName] ?? '';
+ }//end template()
+
+ /**
+ * The variables a template offers, empty for an unknown template.
+ *
+ * @param string $templateName The template name.
+ *
+ * @return array
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function variables(string $templateName): array {
+ $variables = [
+ 'organization_registration' => [
+ 'organization.name' => 'Organization name',
+ 'organization.beoordeling' => 'Organization status (e.g., Actief)',
+ 'organization.type' => 'Organization type (e.g., Leverancier)',
+ 'organization.website' => 'Organization website',
+ ],
+ 'organization_activation' => [
+ 'organization.name' => 'Organization name',
+ 'organization.beoordeling' => 'Organization status (e.g., Actief)',
+ 'organization.type' => 'Organization type',
+ 'organization.website' => 'Organization website',
+ ],
+ 'user_creation' => [
+ 'user.name' => 'User display name',
+ 'user.email' => 'User email address',
+ 'user.username' => 'Username',
+ 'user.organization.name' => 'Organization name (if applicable)',
+ ],
+ ];
+
+ return $variables[$templateName] ?? [];
+ }//end variables()
+
+}//end class
diff --git a/lib/Service/Settings/ObjectTypeLocatorInterface.php b/lib/Service/Settings/ObjectTypeLocatorInterface.php
new file mode 100644
index 000000000..02d17f474
--- /dev/null
+++ b/lib/Service/Settings/ObjectTypeLocatorInterface.php
@@ -0,0 +1,52 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Settings;
+
+/**
+ * Resolves the configured OpenRegister register and schema of an object type
+ * (organisatie, module, beoordeeling, element, ...). Services that only need
+ * these two lookups depend on this interface instead of the whole settings
+ * service.
+ *
+ * @category Service
+ * @package OCA\Stackiq\Service\Settings
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+interface ObjectTypeLocatorInterface {
+
+ /**
+ * The configured schema id of an object type.
+ *
+ * @param string $objectType The object type
+ *
+ * @return int|null The schema id, or null when none is configured
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function getSchemaIdForObjectType(string $objectType): ?int;
+
+ /**
+ * The configured register id of an object type.
+ *
+ * @param string $objectType The object type
+ *
+ * @return int|null The register id, or null when none is configured
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function getRegisterIdForObjectType(string $objectType): ?int;
+}//end interface
diff --git a/lib/Service/Settings/SettingsObjectCounts.php b/lib/Service/Settings/SettingsObjectCounts.php
new file mode 100644
index 000000000..b9c884ec1
--- /dev/null
+++ b/lib/Service/Settings/SettingsObjectCounts.php
@@ -0,0 +1,338 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Settings;
+
+use OCA\Stackiq\Service\SettingsService;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Counts the objects of the voorzieningen and AMEF registers and builds the
+ * statistics the settings page shows. Every count falls back to zero when
+ * OpenRegister or ArchiMate cannot answer.
+ *
+ * @category Service
+ * @package OCA\Stackiq\Service\Settings
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+final class SettingsObjectCounts {
+
+ /**
+ * Voorzieningen config key of each counted schema, with its count key.
+ *
+ * @var array
+ */
+ private const VOORZIENINGEN_COUNT_KEYS = [
+ 'organisatie_schema' => 'totalOrganisatieObjects',
+ 'contactpersoon_schema' => 'totalContactpersoonObjects',
+ 'voorziening_schema' => 'totalVoorzieningObjects',
+ 'voorziening_aanbod_schema' => 'totalVoorzieningAanbodObjects',
+ 'voorziening_versie_schema' => 'totalVoorzieningVersieObjects',
+ 'kwetsbaarheid_schema' => 'totalKwetsbaarheidObjects',
+ 'contract_schema' => 'totalContractObjects',
+ 'standaard_schema' => 'totalStandaardObjects',
+ 'review_schema' => 'totalReviewObjects',
+ 'koppeling_schema' => 'totalKoppelingObjects',
+ 'beoordeeling_schema' => 'totalBeoordeelingObjects',
+ 'module_schema' => 'totalVoorzieningModuleObjects',
+ 'verklaring_schema' => 'totalVerklaringObjects',
+ 'koppeling_gebruik_schema' => 'totalKoppelingGebruikObjects',
+ 'compliancy_schema' => 'totalCompliancyObjects',
+ 'module_gebruik_schema' => 'totalModuleGebruikObjects',
+ 'module_versie_schema' => 'totalModuleVersieObjects',
+ 'sector_schema' => 'totalSectorObjects',
+ 'gebruik_schema' => 'totalGebruikObjects',
+ ];
+
+ /**
+ * AMEF count keys with the ArchiMateService getter that lists their objects.
+ *
+ * @var array
+ */
+ private const AMEF_COUNT_KEYS = [
+ 'totalElementObjects' => 'getElementObjects',
+ 'totalOrganizationObjects' => 'getOrganizationObjects',
+ 'totalViewObjects' => 'getViewObjects',
+ 'totalRelationshipsObjects' => 'getRelationshipObjects',
+ 'totalModelObjects' => 'getModelObjects',
+ ];
+
+ /**
+ * Build the counter.
+ *
+ * @param SettingsService $settings The settings, for the object service and the register configs.
+ * @param ContainerInterface $container The container, for the ArchiMateService.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ private readonly SettingsService $settings,
+ private readonly ContainerInterface $container,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Object counts per voorzieningen schema, zero for every schema that cannot be counted.
+ *
+ * @return array
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function voorzieningenCounts(): array {
+ try {
+ $objectService = $this->settings->getObjectService();
+ if ($objectService === null) {
+ return self::zeroVoorzieningenCounts();
+ }
+
+ $config = $this->settings->getVoorzieningenConfig();
+ $counts = self::zeroVoorzieningenCounts();
+ foreach (self::VOORZIENINGEN_COUNT_KEYS as $configKey => $countKey) {
+ $counts[$countKey] = $this->countSchema(
+ objectService: $objectService,
+ target: ['register' => ($config['register'] ?? null), 'schema' => ($config[$configKey] ?? null), 'configKey' => $configKey]
+ );
+ }
+
+ $this->logger->debug('SettingsService: Retrieved Voorzieningen object counts', $counts);
+
+ return $counts;
+ } catch (\Exception $e) {
+ $this->logger->error(
+ 'SettingsService: Failed to get Voorzieningen object counts',
+ [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]
+ );
+
+ return self::zeroVoorzieningenCounts();
+ }//end try
+
+ }//end voorzieningenCounts()
+
+ /**
+ * Object counts per AMEF type, zero for every type when ArchiMate cannot answer.
+ *
+ * @return array
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function amefCounts(): array {
+ try {
+ // Get ArchiMateService from container to avoid circular dependency.
+ $archiMateService = $this->container->get(\OCA\Stackiq\Service\ArchiMateService::class);
+
+ $counts = [];
+ foreach (self::AMEF_COUNT_KEYS as $countKey => $getter) {
+ $counts[$countKey] = count($archiMateService->$getter());
+ }
+
+ $this->logger->debug(
+ 'SettingsService: Retrieved AMEF object counts',
+ [
+ 'elementObjects' => $counts['totalElementObjects'],
+ 'organizationObjects' => $counts['totalOrganizationObjects'],
+ 'viewObjects' => $counts['totalViewObjects'],
+ 'relationshipObjects' => $counts['totalRelationshipsObjects'],
+ 'modelObjects' => $counts['totalModelObjects'],
+ ]
+ );
+
+ return $counts;
+ } catch (\Exception $e) {
+ $this->logger->error(
+ 'SettingsService: Failed to get AMEF object counts',
+ [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]
+ );
+
+ return self::zeroAmefCounts();
+ }//end try
+
+ }//end amefCounts()
+
+ /**
+ * Statistics for both registers: their config, their object counts and
+ * whether they are configured.
+ *
+ * @return array
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function statistics(): array {
+ try {
+ $statistics = [
+ 'voorzieningen' => [],
+ 'amef' => [],
+ 'timestamp' => time(),
+ ];
+
+ $statistics['voorzieningen'] = $this->voorzieningenStatistics();
+ $statistics['amef'] = $this->amefStatistics();
+
+ return $statistics;
+ } catch (\Exception $e) {
+ $this->logger->error(
+ 'SettingsService: Failed to get object counts statistics',
+ [
+ 'exception' => $e->getMessage(),
+ ]
+ );
+ return [
+ 'voorzieningen' => self::failedVoorzieningenStatistics(message: $e->getMessage()),
+ 'amef' => self::failedAmefStatistics(message: $e->getMessage()),
+ 'timestamp' => time(),
+ 'error' => $e->getMessage(),
+ ];
+ }//end try
+
+ }//end statistics()
+
+ /**
+ * The voorzieningen statistics.
+ *
+ * @return array
+ */
+ private function voorzieningenStatistics(): array {
+ try {
+ $voorzieningenConfig = $this->settings->getVoorzieningenConfig();
+ $voorzieningenCounts = $this->voorzieningenCounts();
+
+ return [
+ 'config' => $voorzieningenConfig,
+ 'object_counts' => $voorzieningenCounts,
+ 'configured' => empty($voorzieningenConfig['register']) === false
+ && empty($voorzieningenConfig['organisatie_schema']) === false,
+ ];
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to get Voorzieningen statistics', ['error' => $e->getMessage()]);
+ return self::failedVoorzieningenStatistics(message: $e->getMessage());
+ }
+
+ }//end voorzieningenStatistics()
+
+ /**
+ * The AMEF statistics.
+ *
+ * @return array
+ */
+ private function amefStatistics(): array {
+ try {
+ $amefConfig = $this->settings->getAmefConfig();
+ $amefCounts = $this->amefCounts();
+
+ return [
+ 'config' => $amefConfig,
+ 'object_counts' => $amefCounts,
+ 'configured' => empty($amefConfig['register_id']) === false
+ && empty($amefConfig['elements_schema']) === false,
+ ];
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to get AMEF statistics', ['error' => $e->getMessage()]);
+ return self::failedAmefStatistics(message: $e->getMessage());
+ }
+
+ }//end amefStatistics()
+
+ /**
+ * Count the objects of one voorzieningen schema.
+ *
+ * @param object $objectService The OpenRegister object service.
+ * @param array $target `register`, `schema` and the `configKey` it came from.
+ *
+ * @return int The count, zero when the schema is not set or counting fails.
+ */
+ private function countSchema(object $objectService, array $target): int {
+ if (empty($target['register']) === true || empty($target['schema']) === true) {
+ return 0;
+ }
+
+ try {
+ $query = [
+ '@self' => [
+ 'register' => (int) $target['register'],
+ 'schema' => (int) $target['schema'],
+ ],
+ ];
+ // A true SQL COUNT via OpenRegister's countSearchObjects(), not
+ // count(searchObjects()), which would load or under-report a large register.
+ return $objectService->countSearchObjects($query);
+ } catch (\Exception $e) {
+ $this->logger->warning("Failed to get {$target['configKey']} count", ['error' => $e->getMessage()]);
+ }
+
+ return 0;
+
+ }//end countSchema()
+
+ /**
+ * Zero counts for every voorzieningen schema.
+ *
+ * @return array
+ */
+ private static function zeroVoorzieningenCounts(): array {
+ return array_fill_keys(array_values(self::VOORZIENINGEN_COUNT_KEYS), 0);
+
+ }//end zeroVoorzieningenCounts()
+
+ /**
+ * Zero counts for every AMEF type.
+ *
+ * @return array
+ */
+ private static function zeroAmefCounts(): array {
+ return array_fill_keys(array_keys(self::AMEF_COUNT_KEYS), 0);
+
+ }//end zeroAmefCounts()
+
+ /**
+ * The voorzieningen statistics when they cannot be read.
+ *
+ * @param string $message The error message.
+ *
+ * @return array
+ */
+ private static function failedVoorzieningenStatistics(string $message): array {
+ return [
+ 'config' => [],
+ 'object_counts' => ['totalOrganisatieObjects' => 0, 'totalContactpersoonObjects' => 0],
+ 'configured' => false,
+ 'error' => $message,
+ ];
+
+ }//end failedVoorzieningenStatistics()
+
+ /**
+ * The AMEF statistics when they cannot be read.
+ *
+ * @param string $message The error message.
+ *
+ * @return array
+ */
+ private static function failedAmefStatistics(string $message): array {
+ return [
+ 'config' => [],
+ 'object_counts' => self::zeroAmefCounts(),
+ 'configured' => false,
+ 'error' => $message,
+ ];
+
+ }//end failedAmefStatistics()
+
+}//end class
diff --git a/lib/Service/Settings/VoorzieningenConfigNormalizer.php b/lib/Service/Settings/VoorzieningenConfigNormalizer.php
new file mode 100644
index 000000000..851bfaf87
--- /dev/null
+++ b/lib/Service/Settings/VoorzieningenConfigNormalizer.php
@@ -0,0 +1,79 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @link https://github.com/ConductionNL/stackiq
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Stackiq\Service\Settings;
+
+/**
+ * Normalises the voorzieningen configuration to the clean format: only
+ * `register` and the known `*_schema` keys, every value a string, every
+ * known schema key present.
+ *
+ * @category Service
+ * @package OCA\Stackiq\Service\Settings
+ *
+ * @spec openspec/specs/settings-service/spec.md
+ */
+final class VoorzieningenConfigNormalizer {
+
+ /**
+ * Normalise a raw or legacy voorzieningen configuration.
+ *
+ * @param array $input Raw/legacy configuration.
+ *
+ * @return array Normalized configuration.
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ public function normalize(array $input): array {
+ $normalized = [];
+
+ // Register id.
+ $normalized['register'] = '';
+ if (isset($input['register']) === true) {
+ $normalized['register'] = (string)$input['register'];
+ }
+
+ // Known schema keys to support - updated to match actual schemas from register.
+ $schemaKeys = [
+ 'organisatie_schema',
+ 'contactpersoon_schema',
+ 'suite_schema',
+ 'dienst_schema',
+ 'kwetsbaarheid_schema',
+ 'gebruik_schema',
+ 'contract_schema',
+ 'koppeling_schema',
+ 'beoordeeling_schema',
+ 'module_schema',
+ 'compliancy_schema',
+ 'moduleVersie_schema',
+ 'sector_schema',
+ 'sbomComponent_schema',
+ 'maintenanceWindow_schema',
+ ];
+
+ // Copy any present schema keys; ignore sources/registers.
+ foreach ($schemaKeys as $key) {
+ $normalized[$key] = '';
+ if (array_key_exists($key, $input) === true && $input[$key] !== null) {
+ $normalized[$key] = (string)$input[$key];
+ }
+ }
+
+ return $normalized;
+ }//end normalize()
+
+}//end class
diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php
index 528f65ebe..60aac1450 100644
--- a/lib/Service/SettingsService.php
+++ b/lib/Service/SettingsService.php
@@ -18,6 +18,10 @@
namespace OCA\Stackiq\Service;
+use OCA\Stackiq\Service\Settings\EmailTemplateDefaults;
+use OCA\Stackiq\Service\Settings\ObjectTypeLocatorInterface;
+use OCA\Stackiq\Service\Settings\SettingsObjectCounts;
+use OCA\Stackiq\Service\Settings\VoorzieningenConfigNormalizer;
use OCP\App\IAppManager;
use OCP\IAppConfig;
use OCP\IGroupManager;
@@ -65,7 +69,7 @@
*
* @spec openspec/specs/settings-service/spec.md
*/
-class SettingsService {
+class SettingsService implements ObjectTypeLocatorInterface {
/**
* The application name for identification and configuration purposes
@@ -146,6 +150,17 @@ class SettingsService {
*/
private const SHARED_ORGANISATION_SCHEMA = 'nc-organisation';
+ /**
+ * The name each role group list goes by in result messages and logs.
+ *
+ * @var array
+ */
+ private const ROLE_GROUP_LABELS = [
+ 'generic' => 'generic user groups',
+ 'organizationAdmin' => 'organization admin groups',
+ 'superUser' => 'super user groups',
+ ];
+
/**
* SettingsService constructor
*
@@ -1709,7 +1724,7 @@ public function loadSettings(bool $force = false): array {
fragmentSig: $fragmentSig
);
- $appId = \OCA\Stackiq\AppInfo\Application::APP_ID;
+ $appId = \OCA\Stackiq\AppInfo\AppIdentity::APP_ID;
// Force-when-stale workaround (register-import-reliability,
// https://github.com/ConductionNL/openregister/issues/2075):
@@ -1809,7 +1824,7 @@ public function loadSettings(bool $force = false): array {
'trace' => $e->getTraceAsString(),
'force_flag' => $force,
'effective_force' => $effectiveForce,
- 'app_id' => \OCA\Stackiq\AppInfo\Application::APP_ID,
+ 'app_id' => \OCA\Stackiq\AppInfo\AppIdentity::APP_ID,
]
);
@@ -2856,52 +2871,7 @@ public function updateEmailTemplate(string $templateName, string $templateConten
* @spec openspec/specs/settings-service/spec.md
*/
public function getDefaultEmailTemplate(string $templateName): string {
- $templates = [
- 'organization_registration' => '
-Welkom bij de Software Catalogus!
-Beste {{ organization.name }},
-Hartelijk welkom bij de Software Catalogus! Uw organisatie is succesvol geregistreerd.
-Met de Software Catalogus kunt u:
-
- - Uw software overzichtelijk beheren
- - Software delen met andere organisaties
- - Ontdekken welke software andere organisaties gebruiken
-
-Uw organisatie heeft de status "{{ organization.beoordeling }}" en kan nu gebruik maken van alle functionaliteiten.
-Heeft u vragen? Neem dan contact met ons op.
-Met vriendelijke groet,
Het Software Catalogus Team
- ',
- 'organization_activation' => '
-Uw organisatie is geactiveerd!
-Beste {{ organization.name }},
-Goed nieuws! Uw organisatie is zojuist geactiveerd in de Software Catalogus.
-Dit betekent dat u nu volledig kunt deelnemen aan:
-
- - Het delen van software informatie
- - Samenwerking met andere organisaties
- - Toegang tot alle catalogus functionaliteiten
-
-Status: {{ organization.beoordeling }}
-U kunt nu inloggen en gebruik maken van alle beschikbare functionaliteiten.
-Met vriendelijke groet,
Het Software Catalogus Team
- ',
- 'user_creation' => '
-Welkom {{ user.name }}!
-Beste {{ user.name }},
-Er is een gebruikersaccount voor u aangemaakt in de Software Catalogus.
-Uw accountgegevens:
-
- - E-mailadres: {{ user.email }}
- - Gebruikersnaam: {{ user.username }}
- - Organisatie: {{ user.organization.name if user.organization }}
-
-U kunt nu inloggen op het platform en gebruik maken van alle functionaliteiten.
-Heeft u vragen over uw account? Neem dan contact met ons op.
-Met vriendelijke groet,
Het Software Catalogus Team
- ',
- ];
-
- return $templates[$templateName] ?? '';
+ return (new EmailTemplateDefaults())->template(templateName: $templateName);
}//end getDefaultEmailTemplate()
/**
@@ -2913,28 +2883,7 @@ public function getDefaultEmailTemplate(string $templateName): string {
* @spec openspec/specs/settings-service/spec.md
*/
public function getEmailTemplateVariables(string $templateName): array {
- $variables = [
- 'organization_registration' => [
- 'organization.name' => 'Organization name',
- 'organization.beoordeling' => 'Organization status (e.g., Actief)',
- 'organization.type' => 'Organization type (e.g., Leverancier)',
- 'organization.website' => 'Organization website',
- ],
- 'organization_activation' => [
- 'organization.name' => 'Organization name',
- 'organization.beoordeling' => 'Organization status (e.g., Actief)',
- 'organization.type' => 'Organization type',
- 'organization.website' => 'Organization website',
- ],
- 'user_creation' => [
- 'user.name' => 'User display name',
- 'user.email' => 'User email address',
- 'user.username' => 'Username',
- 'user.organization.name' => 'Organization name (if applicable)',
- ],
- ];
-
- return $variables[$templateName] ?? [];
+ return (new EmailTemplateDefaults())->variables(templateName: $templateName);
}//end getEmailTemplateVariables()
/**
@@ -3584,7 +3533,7 @@ private function shouldLoadSettings(): bool {
public function getVersionInfo(): array {
try {
// Get the current app version.
- $currentAppVersion = $this->appManager->getAppVersion(\OCA\Stackiq\AppInfo\Application::APP_ID);
+ $currentAppVersion = $this->appManager->getAppVersion(\OCA\Stackiq\AppInfo\AppIdentity::APP_ID);
$this->logger->debug(
'SettingsService: Getting version information',
@@ -3598,7 +3547,7 @@ public function getVersionInfo(): array {
$storedConfigVersion = null;
try {
- $appId = \OCA\Stackiq\AppInfo\Application::APP_ID;
+ $appId = \OCA\Stackiq\AppInfo\AppIdentity::APP_ID;
$storedConfigVersion = $configurationService->getConfiguredAppVersion($appId);
} catch (\Exception $e) {
$this->logger->warning(
@@ -4606,7 +4555,7 @@ public function getVoorzieningenConfig(): array {
// Normalize to the new, clean structure: no *_source or *_register keys,.
// include all known schema keys, and accept legacy 'voorzieningen_*_schema' fallbacks.
- return $this->normalizeVoorzieningenConfig(input: $decoded);
+ return (new VoorzieningenConfigNormalizer())->normalize(input: $decoded);
}//end getVoorzieningenConfig()
/**
@@ -4622,67 +4571,11 @@ public function setVoorzieningenConfig(array $config): void {
$this->clearConfigurationCache();
// Persist only normalized structure.
- $normalized = $this->normalizeVoorzieningenConfig(input: $config);
+ $normalized = (new VoorzieningenConfigNormalizer())->normalize(input: $config);
$jsonConfig = json_encode($normalized, JSON_PRETTY_PRINT);
$this->config->setValueString($this->appName, 'voorzieningen_config', $jsonConfig);
}//end setVoorzieningenConfig()
- /**
- * Normalize voorzieningen configuration to the new, clean format.
- * - Keep only 'register' and individual '*_schema' keys
- * - Drop any '*_source' and '*_register' keys
- * - Ensure all known schema keys are present (null if missing)
- *
- * @param array $input Raw/legacy configuration.
- *
- * @return array Normalized configuration.
- */
- private function normalizeVoorzieningenConfig(array $input): array {
- $normalized = [];
-
- // Register id.
- if (isset($input['register']) === true) {
- $normalized['register'] = (string)$input['register'];
- } else {
- $normalized['register'] = '';
- }
-
- // Known schema keys to support - updated to match actual schemas from register.
- $schemaKeys = [
- 'organisatie_schema',
- 'contactpersoon_schema',
- 'suite_schema',
- 'dienst_schema',
- 'kwetsbaarheid_schema',
- 'gebruik_schema',
- 'contract_schema',
- 'koppeling_schema',
- 'beoordeeling_schema',
- 'module_schema',
- 'compliancy_schema',
- 'moduleVersie_schema',
- 'sector_schema',
- 'sbomComponent_schema',
- 'maintenanceWindow_schema',
- ];
-
- // Copy any present schema keys; ignore sources/registers.
- foreach ($schemaKeys as $key) {
- if (array_key_exists($key, $input) === true) {
- if ($input[$key] === null) {
- $normalized[$key] = '';
- } else {
- $normalized[$key] = (string)$input[$key];
- }
- } else {
- // Accept legacy keys that might be nested under 'voorzieningen_*_schema'.
- $normalized[$key] = '';
- }
- }
-
- return $normalized;
- }//end normalizeVoorzieningenConfig()
-
/**
* Gets AMEF configuration using ArchiMateService to avoid code duplication
*
@@ -4859,7 +4752,7 @@ public function getArchiMateStatus(): array {
$exportDecoded = json_decode($exportStatus, true);
// Get AMEF object counts.
- $amefObjectCounts = $this->getAmefObjectCounts();
+ $amefObjectCounts = $this->objectCounts()->amefCounts();
$importValue = [];
if (is_array($importDecoded) === true) {
@@ -4883,189 +4776,6 @@ public function getArchiMateStatus(): array {
}//end try
}//end getArchiMateStatus()
- /**
- * Get Voorzieningen object counts for statistics
- *
- * @return array Object counts for Voorzieningen schemas
- */
- private function getVoorzieningenObjectCounts(): array {
- try {
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- return [
- 'totalOrganisatieObjects' => 0,
- 'totalContactpersoonObjects' => 0,
- 'totalVoorzieningObjects' => 0,
- 'totalVoorzieningAanbodObjects' => 0,
- 'totalVoorzieningVersieObjects' => 0,
- 'totalKwetsbaarheidObjects' => 0,
- 'totalContractObjects' => 0,
- 'totalStandaardObjects' => 0,
- 'totalReviewObjects' => 0,
- 'totalKoppelingObjects' => 0,
- 'totalBeoordeelingObjects' => 0,
- 'totalVoorzieningModuleObjects' => 0,
- 'totalVerklaringObjects' => 0,
- 'totalKoppelingGebruikObjects' => 0,
- 'totalCompliancyObjects' => 0,
- 'totalModuleGebruikObjects' => 0,
- 'totalModuleVersieObjects' => 0,
- 'totalSectorObjects' => 0,
- 'totalGebruikObjects' => 0,
- ];
- }//end if
-
- $voorzieningenConfig = $this->getVoorzieningenConfig();
- $registerId = $voorzieningenConfig['register'] ?? null;
-
- // Define all schema mappings.
- $schemaMappings = [
- 'organisatie_schema' => 'totalOrganisatieObjects',
- 'contactpersoon_schema' => 'totalContactpersoonObjects',
- 'voorziening_schema' => 'totalVoorzieningObjects',
- 'voorziening_aanbod_schema' => 'totalVoorzieningAanbodObjects',
- 'voorziening_versie_schema' => 'totalVoorzieningVersieObjects',
- 'kwetsbaarheid_schema' => 'totalKwetsbaarheidObjects',
- 'contract_schema' => 'totalContractObjects',
- 'standaard_schema' => 'totalStandaardObjects',
- 'review_schema' => 'totalReviewObjects',
- 'koppeling_schema' => 'totalKoppelingObjects',
- 'beoordeeling_schema' => 'totalBeoordeelingObjects',
- 'module_schema' => 'totalVoorzieningModuleObjects',
- 'verklaring_schema' => 'totalVerklaringObjects',
- 'koppeling_gebruik_schema' => 'totalKoppelingGebruikObjects',
- 'compliancy_schema' => 'totalCompliancyObjects',
- 'module_gebruik_schema' => 'totalModuleGebruikObjects',
- 'module_versie_schema' => 'totalModuleVersieObjects',
- 'sector_schema' => 'totalSectorObjects',
- 'gebruik_schema' => 'totalGebruikObjects',
- ];
-
- $counts = [];
-
- // Initialize all counts to 0.
- foreach ($schemaMappings as $key => $countKey) {
- $counts[$countKey] = 0;
- }
-
- // Count objects for each configured schema.
- foreach ($schemaMappings as $configKey => $countKey) {
- $schemaId = $voorzieningenConfig[$configKey] ?? null;
-
- if ($registerId !== false && $schemaId === true) {
- try {
- $query = [
- '@self' => [
- 'register' => (int)$registerId,
- 'schema' => (int)$schemaId,
- ],
- ];
- // A true SQL COUNT via OpenRegister's countSearchObjects()
- // — NOT count(searchObjects()), which would either
- // hydrate the entire register into memory (unbounded)
- // or, if bounded with `_limit`, silently under-report
- // the total for a register larger than the limit.
- $counts[$countKey] = $objectService->countSearchObjects($query);
- } catch (\Exception $e) {
- $this->logger->warning("Failed to get {$configKey} count", ['error' => $e->getMessage()]);
- }
- }
- }//end foreach
-
- $this->logger->debug('SettingsService: Retrieved Voorzieningen object counts', $counts);
-
- return $counts;
- } catch (\Exception $e) {
- $this->logger->error(
- 'SettingsService: Failed to get Voorzieningen object counts',
- [
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
-
- return [
- 'totalOrganisatieObjects' => 0,
- 'totalContactpersoonObjects' => 0,
- 'totalVoorzieningObjects' => 0,
- 'totalVoorzieningAanbodObjects' => 0,
- 'totalVoorzieningVersieObjects' => 0,
- 'totalKwetsbaarheidObjects' => 0,
- 'totalContractObjects' => 0,
- 'totalStandaardObjects' => 0,
- 'totalReviewObjects' => 0,
- 'totalKoppelingObjects' => 0,
- 'totalBeoordeelingObjects' => 0,
- 'totalVoorzieningModuleObjects' => 0,
- 'totalVerklaringObjects' => 0,
- 'totalKoppelingGebruikObjects' => 0,
- 'totalCompliancyObjects' => 0,
- 'totalModuleGebruikObjects' => 0,
- 'totalModuleVersieObjects' => 0,
- 'totalSectorObjects' => 0,
- 'totalGebruikObjects' => 0,
- ];
- }//end try
- }//end getVoorzieningenObjectCounts()
-
- /**
- * Gets AMEF object counts for consolidated configuration
- *
- * This method retrieves counts of all AMEF object types using the ArchiMateService
- * and returns them in a format suitable for the consolidated configuration.
- *
- * @return array AMEF object counts
- */
- private function getAmefObjectCounts(): array {
- try {
- // Get ArchiMateService from container to avoid circular dependency.
- $archiMateService = $this->container->get(\OCA\Stackiq\Service\ArchiMateService::class);
-
- // Get object counts using ArchiMateService methods.
- $elementObjects = $archiMateService->getElementObjects();
- $organizationObjects = $archiMateService->getOrganizationObjects();
- $viewObjects = $archiMateService->getViewObjects();
- $relationshipObjects = $archiMateService->getRelationshipObjects();
- $modelObjects = $archiMateService->getModelObjects();
-
- $this->logger->debug(
- 'SettingsService: Retrieved AMEF object counts',
- [
- 'elementObjects' => count($elementObjects),
- 'organizationObjects' => count($organizationObjects),
- 'viewObjects' => count($viewObjects),
- 'relationshipObjects' => count($relationshipObjects),
- 'modelObjects' => count($modelObjects),
- ]
- );
-
- return [
- 'totalElementObjects' => count($elementObjects),
- 'totalOrganizationObjects' => count($organizationObjects),
- 'totalViewObjects' => count($viewObjects),
- 'totalRelationshipsObjects' => count($relationshipObjects),
- 'totalModelObjects' => count($modelObjects),
- ];
- } catch (\Exception $e) {
- $this->logger->error(
- 'SettingsService: Failed to get AMEF object counts',
- [
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
-
- // Return zero counts on error to prevent API failures.
- return [
- 'totalElementObjects' => 0,
- 'totalOrganizationObjects' => 0,
- 'totalViewObjects' => 0,
- 'totalRelationshipsObjects' => 0,
- 'totalModelObjects' => 0,
- ];
- }//end try
- }//end getAmefObjectCounts()
-
/**
* Set ArchiMate import status
*
@@ -5277,270 +4987,6 @@ public function clearArchiMateExportStatus(): void {
}
}//end clearArchiMateExportStatus()
- /**
- * Compact existing individual configuration values to JSON format
- * This method reorganizes all the scattered config values into organized JSON objects
- *
- * @return array Compaction results
- * @spec openspec/specs/settings-service/spec.md
- */
- public function compactToJsonConfiguration(): array {
- $results = [
- 'success' => true,
- 'migrated' => [],
- 'errors' => [],
- ];
-
- try {
- // 1. Migrate Voorzieningen configuration.
- $an = $this->appName;
- $voorzieningenConfig = [
- 'register' => $this->config->getValueString(
- $an,
- 'voorzieningen_register',
- ''
- ),
- 'organisatie_schema' => $this->config->getValueString(
- $an,
- 'voorzieningen_organisatie_schema',
- ''
- ),
- 'contactpersoon_schema' => $this->config->getValueString(
- $an,
- 'voorzieningen_contactpersoon_schema',
- ''
- ),
- 'organisatie_source' => $this->config->getValueString(
- $an,
- 'voorzieningen_organisatie_source',
- 'openregister'
- ),
- 'contactpersoon_source' => $this->config->getValueString(
- $an,
- 'voorzieningen_contactpersoon_source',
- 'openregister'
- ),
- 'organisatie_register' => $this->config->getValueString(
- $an,
- 'voorzieningen_organisatie_register',
- ''
- ),
- 'contactpersoon_register' => $this->config->getValueString(
- $an,
- 'voorzieningen_contactpersoon_register',
- ''
- ),
- ];
-
- $this->setVoorzieningenConfig(config: $voorzieningenConfig);
- $results['migrated']['voorzieningen'] = $voorzieningenConfig;
-
- // 2. Migrate AMEF configuration.
- $amefConfig = [
- 'register_id' => $this->config->getValueString(
- $an,
- 'amef_register_id',
- ''
- ),
- 'organizations_schema' => $this->config->getValueString(
- $an,
- 'amef_organizations_schema',
- ''
- ),
- 'elements_schema' => $this->config->getValueString(
- $an,
- 'amef_elements_schema',
- ''
- ),
- 'relationships_schema' => $this->config->getValueString(
- $an,
- 'amef_relationships_schema',
- ''
- ),
- 'views_schema' => $this->config->getValueString(
- $an,
- 'amef_views_schema',
- ''
- ),
- 'models_schema' => $this->config->getValueString(
- $an,
- 'amef_models_schema',
- ''
- ),
- 'organization_source' => $this->config->getValueString(
- $an,
- 'amef_organization_source',
- 'openregister'
- ),
- 'organization_register' => $this->config->getValueString(
- $an,
- 'amef_organization_register',
- ''
- ),
- 'organization_schema' => $this->config->getValueString(
- $an,
- 'amef_organization_schema',
- ''
- ),
- // Note: Duplicated entries with typos kept for backward compatibility.
- 'elementss_schema' => $this->config->getValueString(
- $an,
- 'amef_elementss_schema',
- ''
- ),
- 'organizationss_schema' => $this->config->getValueString(
- $an,
- 'amef_organizationss_schema',
- ''
- ),
- 'relationshipss_schema' => $this->config->getValueString(
- $an,
- 'amef_relationshipss_schema',
- ''
- ),
- ];
-
- $this->setAmefConfig(config: $amefConfig);
- $results['migrated']['amef'] = $amefConfig;
-
- // 3. Migrate Email configuration.
- $emailConfig = [
- 'enabled' => $this->config->getValueString(
- $an,
- 'email_enabled',
- 'false'
- ) === 'true',
- 'transport_type' => $this->config->getValueString(
- $an,
- 'email_transport_type',
- 'smtp'
- ),
- 'smtp_host' => $this->config->getValueString(
- $an,
- 'email_smtp_host',
- ''
- ),
- 'smtp_port' => $this->config->getValueString(
- $an,
- 'email_smtp_port',
- '587'
- ),
- 'smtp_username' => $this->config->getValueString(
- $an,
- 'email_smtp_username',
- ''
- ),
- 'smtp_password' => $this->config->getValueString(
- $an,
- 'email_smtp_password',
- ''
- ),
- 'smtp_encryption' => $this->config->getValueString(
- $an,
- 'email_smtp_encryption',
- 'tls'
- ),
- 'sender_email' => $this->config->getValueString(
- $an,
- 'sender_email',
- ''
- ),
- 'sender_name' => $this->config->getValueString(
- $an,
- 'sender_name',
- ''
- ),
- 'mailjet_api_key' => $this->config->getValueString(
- $an,
- 'email_mailjet_api_key',
- ''
- ),
- 'mailjet_secret_key' => $this->config->getValueString(
- $an,
- 'email_mailjet_secret_key',
- ''
- ),
- 'sendgrid_api_key' => $this->config->getValueString(
- $an,
- 'email_sendgrid_api_key',
- ''
- ),
- 'mailgun_api_key' => $this->config->getValueString(
- $an,
- 'email_mailgun_api_key',
- ''
- ),
- 'mailgun_domain' => $this->config->getValueString(
- $an,
- 'email_mailgun_domain',
- ''
- ),
- 'postmark_api_key' => $this->config->getValueString(
- $an,
- 'email_postmark_api_key',
- ''
- ),
- 'ses_access_key' => $this->config->getValueString(
- $an,
- 'email_ses_access_key',
- ''
- ),
- 'ses_secret_key' => $this->config->getValueString(
- $an,
- 'email_ses_secret_key',
- ''
- ),
- 'ses_region' => $this->config->getValueString(
- $an,
- 'email_ses_region',
- 'us-east-1'
- ),
- 'org_registration_enabled' => $this->config->getValueString(
- $an,
- 'email_org_registration_enabled',
- 'true'
- ) === 'true',
- 'org_activation_enabled' => $this->config->getValueString(
- $an,
- 'email_org_activation_enabled',
- 'true'
- ) === 'true',
- 'user_creation_enabled' => $this->config->getValueString(
- $an,
- 'email_user_creation_enabled',
- 'true'
- ) === 'true',
- 'test_receiver_override' => $this->config->getValueString(
- $an,
- 'test_receiver_override',
- ''
- ),
- ];
-
- $this->setEmailConfig(config: $emailConfig);
- $results['migrated']['email'] = $emailConfig;
-
- $this->logger->info(
- 'Configuration compaction to JSON format completed successfully',
- [
- 'compacted_sections' => array_keys($results['migrated']),
- ]
- );
- } catch (\Exception $e) {
- $results['success'] = false;
- $results['errors'][] = 'Compaction failed: ' . $e->getMessage();
- $this->logger->error(
- 'Configuration compaction to JSON format failed',
- [
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
-
- return $results;
- }//end compactToJsonConfiguration()
-
/**
* Clean up old individual configuration values after compaction
* This method removes the old scattered config values after successful compaction
@@ -5723,6 +5169,16 @@ public function getAllSettings(): array {
}//end try
}//end getAllSettings()
+ /**
+ * The counter behind the object counts and statistics.
+ *
+ * @return SettingsObjectCounts
+ */
+ private function objectCounts(): SettingsObjectCounts {
+ return new SettingsObjectCounts(settings: $this, container: $this->container, logger: $this->logger);
+
+ }//end objectCounts()
+
/**
* Get object counts statistics for all configured registers
*
@@ -5730,92 +5186,7 @@ public function getAllSettings(): array {
* @spec openspec/specs/settings-service/spec.md
*/
public function getObjectCountsStatistics(): array {
- try {
- $statistics = [
- 'voorzieningen' => [],
- 'amef' => [],
- 'timestamp' => time(),
- ];
-
- // Get Voorzieningen statistics.
- try {
- $voorzieningenConfig = $this->getVoorzieningenConfig();
- $voorzieningenCounts = $this->getVoorzieningenObjectCounts();
-
- $statistics['voorzieningen'] = [
- 'config' => $voorzieningenConfig,
- 'object_counts' => $voorzieningenCounts,
- 'configured' => empty($voorzieningenConfig['register']) === false
- && empty($voorzieningenConfig['organisatie_schema']) === false,
- ];
- } catch (\Exception $e) {
- $this->logger->error('Failed to get Voorzieningen statistics', ['error' => $e->getMessage()]);
- $statistics['voorzieningen'] = [
- 'config' => [],
- 'object_counts' => ['totalOrganisatieObjects' => 0, 'totalContactpersoonObjects' => 0],
- 'configured' => false,
- 'error' => $e->getMessage(),
- ];
- }
-
- // Get AMEF statistics.
- try {
- $amefConfig = $this->getAmefConfig();
- $amefCounts = $this->getAmefObjectCounts();
-
- $statistics['amef'] = [
- 'config' => $amefConfig,
- 'object_counts' => $amefCounts,
- 'configured' => empty($amefConfig['register_id']) === false
- && empty($amefConfig['elements_schema']) === false,
- ];
- } catch (\Exception $e) {
- $this->logger->error('Failed to get AMEF statistics', ['error' => $e->getMessage()]);
- $statistics['amef'] = [
- 'config' => [],
- 'object_counts' => [
- 'totalElementObjects' => 0,
- 'totalOrganizationObjects' => 0,
- 'totalViewObjects' => 0,
- 'totalRelationshipsObjects' => 0,
- 'totalModelObjects' => 0,
- ],
- 'configured' => false,
- 'error' => $e->getMessage(),
- ];
- }//end try
-
- return $statistics;
- } catch (\Exception $e) {
- $this->logger->error(
- 'SettingsService: Failed to get object counts statistics',
- [
- 'exception' => $e->getMessage(),
- ]
- );
- return [
- 'voorzieningen' => [
- 'config' => [],
- 'object_counts' => ['totalOrganisatieObjects' => 0, 'totalContactpersoonObjects' => 0],
- 'configured' => false,
- 'error' => $e->getMessage(),
- ],
- 'amef' => [
- 'config' => [],
- 'object_counts' => [
- 'totalElementObjects' => 0,
- 'totalOrganizationObjects' => 0,
- 'totalViewObjects' => 0,
- 'totalRelationshipsObjects' => 0,
- 'totalModelObjects' => 0,
- ],
- 'configured' => false,
- 'error' => $e->getMessage(),
- ],
- 'timestamp' => time(),
- 'error' => $e->getMessage(),
- ];
- }//end try
+ return $this->objectCounts()->statistics();
}//end getObjectCountsStatistics()
/**
@@ -5855,36 +5226,7 @@ public function getAllEmailTemplates(): array {
* @spec openspec/specs/settings-service/spec.md
*/
public function updateGenericUserGroups(array $groups): array {
- try {
- $validation = $this->validateGroups(groups: $groups);
-
- if (empty($validation['invalid']) === false) {
- return [
- 'success' => false,
- 'message' => 'Invalid group names provided',
- 'validation' => $validation,
- ];
- }
-
- $this->setGenericUserGroups(groups: $validation['valid']);
-
- return [
- 'success' => true,
- 'message' => 'Generic user groups updated successfully',
- 'groups' => $validation['valid'],
- ];
- } catch (\Exception $e) {
- $this->logger->error(
- 'SettingsService: Failed to update generic user groups',
- [
- 'exception' => $e->getMessage(),
- ]
- );
- return [
- 'success' => false,
- 'message' => 'Failed to update generic user groups: ' . $e->getMessage(),
- ];
- }//end try
+ return $this->updateRoleGroups(groups: $groups, role: 'generic');
}//end updateGenericUserGroups()
/**
@@ -5896,36 +5238,7 @@ public function updateGenericUserGroups(array $groups): array {
* @spec openspec/specs/settings-service/spec.md
*/
public function updateOrganizationAdminGroups(array $groups): array {
- try {
- $validation = $this->validateGroups(groups: $groups);
-
- if (empty($validation['invalid']) === false) {
- return [
- 'success' => false,
- 'message' => 'Invalid group names provided',
- 'validation' => $validation,
- ];
- }
-
- $this->setOrganizationAdminGroups(groups: $validation['valid']);
-
- return [
- 'success' => true,
- 'message' => 'Organization admin groups updated successfully',
- 'groups' => $validation['valid'],
- ];
- } catch (\Exception $e) {
- $this->logger->error(
- 'SettingsService: Failed to update organization admin groups',
- [
- 'exception' => $e->getMessage(),
- ]
- );
- return [
- 'success' => false,
- 'message' => 'Failed to update organization admin groups: ' . $e->getMessage(),
- ];
- }//end try
+ return $this->updateRoleGroups(groups: $groups, role: 'organizationAdmin');
}//end updateOrganizationAdminGroups()
/**
@@ -5937,6 +5250,22 @@ public function updateOrganizationAdminGroups(array $groups): array {
* @spec openspec/specs/settings-service/spec.md
*/
public function updateSuperUserGroups(array $groups): array {
+ return $this->updateRoleGroups(groups: $groups, role: 'superUser');
+ }//end updateSuperUserGroups()
+
+ /**
+ * Validate a role's group list and store it when every name is valid.
+ *
+ * @param array $groups Groups to set.
+ * @param string $role The role: generic, organizationAdmin or superUser.
+ *
+ * @psalm-param 'generic'|'organizationAdmin'|'superUser' $role
+ *
+ * @return array Update result with validation.
+ * @spec openspec/specs/settings-service/spec.md
+ */
+ private function updateRoleGroups(array $groups, string $role): array {
+ $label = self::ROLE_GROUP_LABELS[$role];
try {
$validation = $this->validateGroups(groups: $groups);
@@ -5948,26 +5277,25 @@ public function updateSuperUserGroups(array $groups): array {
];
}
- $this->setSuperUserGroups(groups: $validation['valid']);
+ match ($role) {
+ 'generic' => $this->setGenericUserGroups(groups: $validation['valid']),
+ 'organizationAdmin' => $this->setOrganizationAdminGroups(groups: $validation['valid']),
+ 'superUser' => $this->setSuperUserGroups(groups: $validation['valid']),
+ };
return [
'success' => true,
- 'message' => 'Super user groups updated successfully',
+ 'message' => ucfirst($label) . ' updated successfully',
'groups' => $validation['valid'],
];
} catch (\Exception $e) {
- $this->logger->error(
- 'SettingsService: Failed to update super user groups',
- [
- 'exception' => $e->getMessage(),
- ]
- );
+ $this->logger->error('SettingsService: Failed to update ' . $label, ['exception' => $e->getMessage()]);
return [
'success' => false,
- 'message' => 'Failed to update super user groups: ' . $e->getMessage(),
+ 'message' => 'Failed to update ' . $label . ': ' . $e->getMessage(),
];
}//end try
- }//end updateSuperUserGroups()
+ }//end updateRoleGroups()
// ======================================================.
// FOCUSED ENDPOINT METHODS FOR PERFORMANCE OPTIMIZATION.
@@ -6341,8 +5669,8 @@ public function updateVoorzieningenConfig(array $config): array {
public function getObjectsCounts(): array {
try {
$counts = [
- 'voorzieningen' => $this->getVoorzieningenObjectCounts(),
- 'amef' => $this->getAmefObjectCounts(),
+ 'voorzieningen' => $this->objectCounts()->voorzieningenCounts(),
+ 'amef' => $this->objectCounts()->amefCounts(),
'timestamp' => time(),
];
diff --git a/lib/Service/Stackiq/ApiClient.php b/lib/Service/Stackiq/ApiClient.php
deleted file mode 100644
index 8609b62a3..000000000
--- a/lib/Service/Stackiq/ApiClient.php
+++ /dev/null
@@ -1,155 +0,0 @@
-
- * @copyright 2024 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- * @link https://github.com/ConductionNL/stackiq
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- *
- * SPDX-FileCopyrightText: 2026 Conduction B.V.
- * SPDX-License-Identifier: EUPL-1.2
- */
-
-declare(strict_types=1);
-
-namespace OCA\Stackiq\Service\Stackiq;
-
-use OCA\OpenRegister\Contract\ObjectServiceInterface;
-use Psr\Log\LoggerInterface;
-
-/**
- * Handles all API communication for the Stackiq domain.
- *
- * StackiqService delegates API fetch operations to this class,
- * keeping its own constructor coupling below the PHPMD CouplingBetweenObjects
- * threshold and keeping its methods below ExcessiveMethodLength.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
-class ApiClient {
- /**
- * Constructor.
- *
- * @param ObjectServiceInterface $objectService The OpenRegister object service.
- * @param LoggerInterface $logger Logger instance.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function __construct(
- private readonly ObjectServiceInterface $objectService,
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
-
- /**
- * Fetch objects for a given register and schema with optional filters.
- *
- * @param string $register The register slug to query.
- * @param string $schema The schema slug to query.
- * @param array $filters Optional filter parameters.
- * @param int $limit Maximum number of results (default: 100).
- * @param int $offset Pagination offset (default: 0).
- *
- * @return array Paginated results with 'results' and 'total' keys.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function fetchObjects(
- string $register,
- string $schema,
- array $filters = [],
- int $limit = 100,
- int $offset = 0,
- ): array {
- $query = array_merge(
- $filters,
- [
- '_register' => $register,
- '_schema' => $schema,
- '_limit' => $limit,
- '_offset' => $offset,
- ]
- );
-
- $this->logger->debug(
- 'ApiClient: Fetching objects',
- ['register' => $register, 'schema' => $schema, 'limit' => $limit]
- );
-
- try {
- return $this->objectService->searchObjectsPaginated($query);
- } catch (\Exception $e) {
- $this->logger->error(
- 'ApiClient: Failed to fetch objects',
- ['register' => $register, 'schema' => $schema, 'exception' => $e->getMessage()]
- );
- return ['results' => [], 'total' => 0];
- }
-
- }//end fetchObjects()
-
- /**
- * Fetch a single object by ID from the given register and schema.
- *
- * @param string $id The object UUID or ID.
- * @param string $register The register slug.
- * @param string $schema The schema slug.
- *
- * @return object|null The object entity, or null if not found.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function fetchObject(string $id, string $register, string $schema): ?object {
- try {
- return $this->objectService->find(
- id: $id,
- register: $register,
- schema: $schema
- );
- } catch (\Exception $e) {
- $this->logger->warning(
- 'ApiClient: Object not found',
- ['id' => $id, 'register' => $register, 'schema' => $schema, 'exception' => $e->getMessage()]
- );
- return null;
- }
-
- }//end fetchObject()
-
- /**
- * Save (create or update) an object in the given register and schema.
- *
- * @param string $register The register slug.
- * @param string $schema The schema slug.
- * @param array $data The object data to persist.
- *
- * @return object|null The saved object entity, or null on failure.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function saveObject(string $register, string $schema, array $data): ?object {
- try {
- return $this->objectService->saveObject(
- register: $register,
- schema: $schema,
- object: $data
- );
- } catch (\Exception $e) {
- $this->logger->error(
- 'ApiClient: Failed to save object',
- ['register' => $register, 'schema' => $schema, 'exception' => $e->getMessage()]
- );
- return null;
- }
-
- }//end saveObject()
-}//end class
diff --git a/lib/Service/Stackiq/ConflictResolver.php b/lib/Service/Stackiq/ConflictResolver.php
deleted file mode 100644
index 66d7b6768..000000000
--- a/lib/Service/Stackiq/ConflictResolver.php
+++ /dev/null
@@ -1,172 +0,0 @@
-
- * @copyright 2024 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- * @link https://github.com/ConductionNL/stackiq
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- *
- * SPDX-FileCopyrightText: 2026 Conduction B.V.
- * SPDX-License-Identifier: EUPL-1.2
- */
-
-declare(strict_types=1);
-
-namespace OCA\Stackiq\Service\Stackiq;
-
-use Psr\Log\LoggerInterface;
-
-/**
- * Resolves conflicts and deduplicates entries in the Stackiq domain.
- *
- * StackiqService delegates all conflict detection and resolution
- * methods to this class, shrinking its own complexity metrics.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
-class ConflictResolver {
- /**
- * Constructor.
- *
- * @param LoggerInterface $logger Logger instance.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function __construct(
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
-
- /**
- * Detect whether two object data arrays represent the same logical entity.
- *
- * Compares canonical identity fields (uuid, kvkNummer, oin) to determine
- * whether an incoming record would duplicate an existing one.
- *
- * @param array $existing The existing data record.
- * @param array $incoming The incoming data record to check.
- *
- * @return bool True when the records are considered duplicates.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function isDuplicate(array $existing, array $incoming): bool {
- // UUID match is definitive.
- if ($this->matchesOnField(a: $existing, b: $incoming, field: 'uuid') === true) {
- return true;
- }
-
- // KVK number match (Dutch company registry).
- if ($this->matchesOnField(a: $existing, b: $incoming, field: 'kvkNummer') === true) {
- return true;
- }
-
- // OIN match (Dutch government unique identifier).
- if ($this->matchesOnField(a: $existing, b: $incoming, field: 'oin') === true) {
- return true;
- }
-
- return false;
- }//end isDuplicate()
-
- /**
- * Resolve a conflict between an existing and an incoming record.
- *
- * When both records have the same identity but different data, the incoming
- * record's non-null fields are merged on top of the existing record (last-write-wins
- * per field).
- *
- * @param array $existing The existing record.
- * @param array $incoming The incoming (newer) record.
- *
- * @return array The merged record.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function resolve(array $existing, array $incoming): array {
- $merged = $existing;
-
- foreach ($incoming as $key => $value) {
- // Prefer incoming non-null values.
- if ($value !== null) {
- $merged[$key] = $value;
- }
- }
-
- $this->logger->debug(
- 'ConflictResolver: Resolved conflict between existing and incoming record',
- ['changedFields' => array_keys(array_diff_assoc($merged, $existing))]
- );
-
- return $merged;
- }//end resolve()
-
- /**
- * Deduplicate a list of data records using isDuplicate().
- *
- * The first occurrence of each identity is kept; subsequent duplicates
- * are merged into it using resolve().
- *
- * @param array> $records The list of records to deduplicate.
- *
- * @return array> Deduplicated record list.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function deduplicate(array $records): array {
- $unique = [];
-
- foreach ($records as $record) {
- $found = false;
- foreach ($unique as &$existing) {
- if ($this->isDuplicate(existing: $existing, incoming: $record) === true) {
- $existing = $this->resolve(existing: $existing, incoming: $record);
- $found = true;
- break;
- }
- }
-
- unset($existing);
-
- if ($found === false) {
- $unique[] = $record;
- }
- }
-
- $this->logger->debug(
- 'ConflictResolver: Deduplicated records',
- ['before' => count($records), 'after' => count($unique)]
- );
-
- return $unique;
- }//end deduplicate()
-
- /**
- * Check whether two records share the same value for a given field.
- *
- * @param array $a First record.
- * @param array $b Second record.
- * @param string $field The field name to compare.
- *
- * @return bool True when both records have the same non-empty value for the field.
- */
- private function matchesOnField(array $a, array $b, string $field): bool {
- $valA = $a[$field] ?? null;
- $valB = $b[$field] ?? null;
-
- if ($valA === null || $valB === null || $valA === '' || $valB === '') {
- return false;
- }
-
- return $valA === $valB;
- }//end matchesOnField()
-}//end class
diff --git a/lib/Service/Stackiq/ContactPersonHandler.php b/lib/Service/Stackiq/ContactPersonHandler.php
index 13d312fad..4de540214 100644
--- a/lib/Service/Stackiq/ContactPersonHandler.php
+++ b/lib/Service/Stackiq/ContactPersonHandler.php
@@ -18,6 +18,7 @@
namespace OCA\Stackiq\Service\Stackiq;
+use OCA\Stackiq\Service\OrganisationSlug;
use OCA\Stackiq\Service\SymfonyEmailService;
use OCP\App\IAppManager;
use OCP\IAppConfig;
@@ -1210,7 +1211,7 @@ public function storeContactNameFields(\OCP\IUser $user, array $contactData): vo
}//end if
// Sync e-mailadres to user's email property.
- $email = $contactData['e-mailadres'] ?? $contactData['email'] ?? '';
+ $email = $contactData['email'] ?? $contactData['e-mailadres'] ?? '';
if (empty($email) === false && $email !== $user->getEMailAddress()) {
$user->setEMailAddress($email);
$this->_logger->info(
@@ -2396,9 +2397,10 @@ public function addUserToOrganizationEntity(
$organisation = $this->ensureOrganizationEntity(organizationUuid: $organizationUuid);
+ // The reason there is none is logged by ensureOrganizationEntity().
if ($organisation === null) {
- $this->_logger->error(
- 'ContactPersonHandler: Failed to create organization entity',
+ $this->_logger->info(
+ 'ContactPersonHandler: No organization entity, user not added',
[
'organizationUuid' => $organizationUuid,
]
@@ -2522,23 +2524,14 @@ private function ensureOrganizationEntity(string $organizationUuid): ?\OCA\OpenR
$description = $organizationData['description'] ?? $organizationData['longDescription'] ?? null;
$organizationDescription = $description ?? $organizationData['description'] ?? '';
- // Check if an entity with the same slug already exists (prevents unique constraint violation).
+ // Another organisation with this name's slug is never taken over: the
+ // organisatie stays without an entity until an administrator links it.
try {
- $slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', strtolower($organizationName)));
- $slug = trim($slug, '-');
- $existingBySlug = $organisationMapper->findBySlug($slug);
- $this->_logger->info(
- 'ContactPersonHandler: Organization entity already exists (found by slug)',
- [
- 'organizationUuid' => $organizationUuid,
- 'slug' => $slug,
- 'existingUuid' => $existingBySlug->getUuid(),
- 'existingName' => $existingBySlug->getName(),
- ]
- );
- return $existingBySlug;
+ $existingBySlug = $organisationMapper->findBySlug((new OrganisationSlug())->fromName(name: $organizationName));
+ $this->logSlugConflict(organizationUuid: $organizationUuid, existingUuid: $existingBySlug->getUuid());
+ return null;
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- // No existing entity by slug — proceed with creation.
+ // No organisation has this slug: create one.
}
$this->_logger->info(
@@ -2572,6 +2565,11 @@ private function ensureOrganizationEntity(string $organizationUuid): ?\OCA\OpenR
return $organisation;
} catch (\Exception $e) {
+ if (is_a($e, OrganisationSlug::CONFLICT_EXCEPTION) === true) {
+ $this->logSlugConflict(organizationUuid: $organizationUuid, existingUuid: null);
+ return null;
+ }
+
$this->_logger->error(
'ContactPersonHandler: Failed to ensure organization entity',
[
@@ -2586,6 +2584,24 @@ private function ensureOrganizationEntity(string $organizationUuid): ?\OCA\OpenR
}//end try
}//end ensureOrganizationEntity()
+ /**
+ * Log an organisatie left without an entity because its name's slug is taken.
+ *
+ * @param string $organizationUuid The organisatie uuid.
+ * @param string|null $existingUuid The uuid of the organisation holding the slug, when known.
+ *
+ * @return void
+ */
+ private function logSlugConflict(string $organizationUuid, ?string $existingUuid): void {
+ $this->_logger->warning(
+ 'ContactPersonHandler: an organisation with this name already exists; the organisatie is left unlinked for an administrator',
+ [
+ 'organizationUuid' => $organizationUuid,
+ 'existingOrganisationUuid' => $existingUuid,
+ ]
+ );
+ }//end logSlugConflict()
+
/**
* Strips spaces and non-alphanumeric characters from voornaam/achternaam
* and lowercases the result so they can be safely concatenated into a
diff --git a/lib/Service/Stackiq/DataMapper.php b/lib/Service/Stackiq/DataMapper.php
deleted file mode 100644
index c0244aa7e..000000000
--- a/lib/Service/Stackiq/DataMapper.php
+++ /dev/null
@@ -1,177 +0,0 @@
-
- * @copyright 2024 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- * @link https://github.com/ConductionNL/stackiq
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- *
- * SPDX-FileCopyrightText: 2026 Conduction B.V.
- * SPDX-License-Identifier: EUPL-1.2
- */
-
-declare(strict_types=1);
-
-namespace OCA\Stackiq\Service\Stackiq;
-
-use Psr\Log\LoggerInterface;
-
-/**
- * Maps and transforms data between external API formats and the Stackiq domain model.
- *
- * StackiqService delegates all data transformation methods to this class,
- * shrinking its own method bodies below ExcessiveMethodLength.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
-class DataMapper {
- /**
- * Constructor.
- *
- * @param LoggerInterface $logger Logger instance.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function __construct(
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
-
- /**
- * Map an organisation object data array to the OpenRegister format.
- *
- * Normalises field names, applies type coercions, and removes undefined keys.
- *
- * @param array $objectData The raw organisation data.
- *
- * @return array The mapped data suitable for saving to OpenRegister.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function mapOrganizationToOpenRegister(array $objectData): array {
- $mapped = $this->normalizeOrganizationFields(data: $objectData);
- $mapped = $this->coerceOrganizationTypes(data: $mapped);
-
- $this->logger->debug(
- 'DataMapper: Mapped organization data',
- ['fieldCount' => count($mapped)]
- );
-
- return $mapped;
- }//end mapOrganizationToOpenRegister()
-
- /**
- * Map a contact person object data array to a Nextcloud user profile.
- *
- * @param array $contactData The raw contact person data.
- *
- * @return array The mapped data suitable for user profile updates.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function mapContactToUserProfile(array $contactData): array {
- $emailRaw = $contactData['e-mailadres'] ?? $contactData['email'] ?? '';
-
- return [
- 'email' => strtolower(trim((string)$emailRaw)),
- 'displayName' => $this->buildDisplayName(data: $contactData),
- 'phone' => $contactData['telefoonnummer'] ?? '',
- ];
-
- }//end mapContactToUserProfile()
-
- /**
- * Map a status string from Dutch to a boolean active indicator.
- *
- * @param string $status Raw status value from the data store.
- *
- * @return bool True when the status represents an active record.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2
- */
- public function mapStatusToActive(string $status): bool {
- return in_array(
- needle: strtolower($status),
- haystack: ['actief', 'active', 'enabled', 'true'],
- strict: true
- );
-
- }//end mapStatusToActive()
-
- /**
- * Normalise field names in the organisation data array.
- *
- * @param array $data Raw data array.
- *
- * @return array Normalised data.
- */
- private function normalizeOrganizationFields(array $data): array {
- // The `name` alias block that used to live here mapped the incoming key
- // `name` onto the then-canonical `naam`. The canonical property IS `name`
- // now, so the alias and its target are the same key and the block became
- // `false` — a
- // contradiction that can never run. Removed rather than left as dead code.
- if (isset($data['status']) === false && isset($data['actief']) === true) {
- $data['status'] = 'inactief';
- if ($data['actief'] === true) {
- $data['status'] = 'actief';
- }
-
- unset($data['actief']);
- }
-
- return $data;
- }//end normalizeOrganizationFields()
-
- /**
- * Apply type coercions to organisation data values.
- *
- * @param array $data Data after field normalisation.
- *
- * @return array Type-coerced data.
- */
- private function coerceOrganizationTypes(array $data): array {
- // Ensure integer fields are cast.
- $intFields = ['kvkNummer', 'oin', 'toezichthouderOin'];
- foreach ($intFields as $field) {
- if (isset($data[$field]) === true && is_numeric($data[$field]) === true) {
- $data[$field] = (int)$data[$field];
- }
- }
-
- return $data;
- }//end coerceOrganizationTypes()
-
- /**
- * Build a display name from contact person data.
- *
- * @param array $data Contact person data.
- *
- * @return string Assembled display name.
- */
- private function buildDisplayName(array $data): string {
- $parts = array_filter(
- [
- $data['voornaam'] ?? '',
- $data['tussenvoegsel'] ?? '',
- $data['achternaam'] ?? '',
- ]
- );
-
- $name = implode(' ', $parts);
- if ($name !== '') {
- return $name;
- }
-
- return $data['name'] ?? '';
- }//end buildDisplayName()
-}//end class
diff --git a/lib/Service/StackiqContactSyncService.php b/lib/Service/StackiqContactSyncService.php
index 0428654c9..df51e25db 100644
--- a/lib/Service/StackiqContactSyncService.php
+++ b/lib/Service/StackiqContactSyncService.php
@@ -199,7 +199,7 @@ public function syncToContacts(string $objectType, array $record): ?string {
* @return ?string The contacts UID, or null when it could not be resolved or created.
*
* @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md
- * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ * @spec openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/tasks.md#task-2
*/
public function syncToNamedAddressBook(
string $objectType,
@@ -267,7 +267,7 @@ public function syncToNamedAddressBook(
* none when the user has no such address book.
*
* @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md
- * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ * @spec openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/tasks.md#task-2
*/
public function searchNamedAddressBook(string $query, string $addressBookUri, array $properties): array {
$contacts = [];
@@ -301,7 +301,7 @@ public function searchNamedAddressBook(string $query, string $addressBookUri, ar
*
* @return string|null The contact UID.
*
- * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2
+ * @spec openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/tasks.md#task-2
*/
private function namedAddressBookContactByEmail(array $record, string $addressBookUri, ?string $fixedUid): ?string {
if ($fixedUid !== null) {
diff --git a/lib/Service/StackiqService.php b/lib/Service/StackiqService.php
deleted file mode 100644
index 7fa7bcc41..000000000
--- a/lib/Service/StackiqService.php
+++ /dev/null
@@ -1,3647 +0,0 @@
-
- * @copyright 2024 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- * @link https://github.com/ConductionNL/stackiq
- *
- * @spec openspec/specs/method-decomposition/spec.md
- */
-
-declare(strict_types=1);
-
-namespace OCA\Stackiq\Service;
-
-use OCA\Stackiq\Service\Stackiq\ContactPersonHandler;
-use OCA\Stackiq\Service\Stackiq\GroupHandler;
-use OCA\Stackiq\Service\Stackiq\HierarchyHandler;
-use OCA\Stackiq\Service\Stackiq\OrganizationHandler;
-use OCP\App\IAppManager;
-use OCP\IGroupManager;
-use OCP\IUserManager;
-use OCP\IUserSession;
-use Psr\Container\ContainerInterface;
-use Psr\Log\LoggerInterface;
-use RuntimeException;
-
-/**
- * Service for handling software catalog operations.
- *
- * Provides functionality for user management, contact processing,
- * email notifications, and object lifecycle management.
- *
- * @category Service
- * @package OCA\Stackiq\Service
- * @author Conduction b.v.
- * @copyright 2024 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- * @link https://github.com/ConductionNL/stackiq
- *
- * @SuppressWarnings(PHPMD.ExcessiveClassLength)
- * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
- * @SuppressWarnings(PHPMD.TooManyMethods)
- * @SuppressWarnings(PHPMD.TooManyPublicMethods)
- * @SuppressWarnings(PHPMD.CyclomaticComplexity)
- * @SuppressWarnings(PHPMD.NPathComplexity)
- * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
- * @SuppressWarnings(PHPMD.LongVariable)
- * @SuppressWarnings(PHPMD.ShortVariable)
- * @SuppressWarnings(PHPMD.MissingImport)
- * @SuppressWarnings(PHPMD.UnusedLocalVariable)
- * @SuppressWarnings(PHPMD.UnusedPrivateMethod)
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- * @SuppressWarnings(PHPMD.Superglobals)
- * @SuppressWarnings(PHPMD.CamelCaseVariableName)
- * @SuppressWarnings(PHPMD.CamelCaseParameterName)
- * @SuppressWarnings(PHPMD.UndefinedVariable)
- * @SuppressWarnings(PHPMD.CountInLoopExpression)
- */
-class StackiqService {
-
- /**
- * The name of the app
- *
- * @var string
- */
- private string $appName;
-
- /**
- * StackiqService constructor
- *
- * @param OrganizationHandler $_organizationHandler Organization handler.
- * @param ContactPersonHandler $_contactPersonHandler Contact person handler.
- * @param GroupHandler $_groupHandler Group handler.
- * @param HierarchyHandler $_hierarchyHandler Hierarchy handler.
- * @param SymfonyEmailService $_emailService Email service.
- * @param LoggerInterface $_logger Logger interface.
- * @param ContainerInterface $_container Container interface.
- * @param IAppManager $_appManager App manager interface.
- * @param IUserSession $_userSession User session interface.
- * @param IUserManager $_userManager User manager interface.
- * @param IGroupManager $_groupManager Group manager interface.
- */
- public function __construct(
- private readonly OrganizationHandler $_organizationHandler,
- private readonly ContactPersonHandler $_contactPersonHandler,
- private readonly GroupHandler $_groupHandler,
- private readonly HierarchyHandler $_hierarchyHandler,
- private readonly SymfonyEmailService $_emailService,
- private readonly LoggerInterface $_logger,
- private readonly ContainerInterface $_container,
- private readonly IAppManager $_appManager,
- private readonly IUserSession $_userSession,
- private readonly IUserManager $_userManager,
- private readonly IGroupManager $_groupManager,
- ) {
- $this->appName = 'stackiq';
- }//end __construct()
-
- /**
- * Gets the ObjectService instance
- *
- * @return \OCA\OpenRegister\Contract\ObjectServiceInterface|null
- */
- private function getObjectService(): ?\OCA\OpenRegister\Contract\ObjectServiceInterface {
- if ($this->_appManager->isEnabledForUser(appId: 'openregister') === false) {
- return null;
- }
-
- try {
- return $this->_container->get('OCA\\OpenRegister\\Service\\ObjectService');
- } catch (\Exception $e) {
- $this->_logger->error('Failed to get ObjectService: ' . $e->getMessage());
- return null;
- }
- }//end getObjectService()
-
- /**
- * Gets the OrganisationService instance
- *
- * @return \OCA\OpenRegister\Service\OrganisationService|null
- */
- private function getOrganisationService(): ?\OCA\OpenRegister\Service\OrganisationService {
- if ($this->_appManager->isEnabledForUser(appId: 'openregister') === false) {
- return null;
- }
-
- try {
- return $this->_container->get('OCA\\OpenRegister\\Service\\OrganisationService');
- } catch (\Exception $e) {
- $this->_logger->error('Failed to get OrganisationService: ' . $e->getMessage());
- return null;
- }
- }//end getOrganisationService()
-
- /**
- * Gets the OrganisationMapper instance
- *
- * OpenRegister is an optional capability for this service (ADR-083 rule 1),
- * so the mapper is reached the same way the two services above are: the app
- * is asked whether OpenRegister is available, and a failed resolution
- * degrades to null with a logged error rather than escaping as a raw
- * container exception. Callers must treat null as "OpenRegister is not
- * available" and take their own not-available branch.
- *
- * @return \OCA\OpenRegister\Db\OrganisationMapper|null
- */
- private function getOrganisationMapper(): ?\OCA\OpenRegister\Db\OrganisationMapper {
- if ($this->_appManager->isEnabledForUser(appId: 'openregister') === false) {
- return null;
- }
-
- try {
- return $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper');
- } catch (\Exception $e) {
- $this->_logger->error('Failed to get OrganisationMapper: ' . $e->getMessage());
- return null;
- }
- }//end getOrganisationMapper()
-
- /**
- * Processes a contactpersoon object to create an inactive user
- *
- * If the contactpersoon object doesn't have a user or the user is missing,
- * this method will create an inactive user account.
- *
- * @param object $contactPersonObject The contactpersoon object to process
- * @param bool $isUpdate Whether this is an update operation (defaults to false)
- *
- * @return bool True if processing was successful
- * @throws \Exception If processing fails
- *
- * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $isUpdate is a simple create-vs-update toggle
- *
- * @spec openspec/specs/method-decomposition/spec.md
- */
- public function processContactpersoon(object $contactPersonObject, bool $isUpdate = false): bool {
- $startTime = microtime(true);
-
- try {
- $objectId = $contactPersonObject->getId();
- $objectData = $contactPersonObject->getObject();
-
- $this->_logger->info(
- 'StackiqService: Starting contactpersoon processing',
- [
- 'objectId' => $objectId,
- 'objectData' => $objectData,
- 'timestamp' => date('Y-m-d H:i:s'),
- ]
- );
-
- // Delegate to contact person handler.
- $this->_logger->debug(
- 'StackiqService: Delegating to ContactPersonHandler for contactpersoon processing',
- [
- 'objectId' => $objectId,
- ]
- );
-
- $result = $this->_contactPersonHandler->processContactpersoon($contactPersonObject, $isUpdate);
-
- $this->_logger->info(
- 'StackiqService: ContactPersonHandler processing completed',
- [
- 'objectId' => $objectId,
- 'result' => $result,
- 'processingTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms',
- ]
- );
-
- if ($result === true) {
- // Get the username from the processed object.
- $updatedObjectData = $contactPersonObject->getObject();
- $username = $updatedObjectData['username'] ?? '';
-
- $this->_logger->info(
- 'StackiqService: Username extracted from processed object',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'hasUsername' => empty($username) === false,
- ]
- );
-
- if (empty($username) === false) {
- // NOTE: Group assignment is already handled by ContactPersonHandler.assignUserGroups().
- // during user creation, so we don't need to call GroupHandler.updateUserGroups() here.
- // as it would overwrite the correct group assignments.
- // Ensure organization has beheerder and set up manager relationships.
- $this->_logger->debug(
- 'StackiqService: Ensuring organization beheerder',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- ]
- );
-
- $this->_hierarchyHandler->ensureOrganizationBeheerder($contactPersonObject, $username);
-
- // Set user to inactive initially.
- $this->_logger->debug(
- 'StackiqService: Setting user to inactive',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- ]
- );
-
- $this->_contactPersonHandler->setUserInactive($username);
-
- $this->_logger->info(
- 'StackiqService: User setup completed',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'totalProcessingTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms',
- ]
- );
-
- // Add the newly created user to the organization entity.
- $organization = $objectData['organization'] ?? null;
- if (empty($organization) === false) {
- $this->_logger->info(
- 'StackiqService: Adding user to organization entity',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'organization' => $organization,
- ]
- );
-
- try {
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- $this->_logger->warning(
- 'StackiqService: OpenRegister OrganisationMapper not available, skipping organization membership',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'organization' => $organization,
- ]
- );
- // Nothing follows this block but `return $result;`, so this is the
- // same exit the method would take after skipping the membership work.
- return $result;
- }
-
- $organisation = $organisationMapper->findByUuid($organization);
-
- if (empty($organisation) === false) {
- $currentUsers = $organisation->getUsers() ?? [];
- if (in_array($username, $currentUsers) === false) {
- $currentUsers[] = $username;
- $organisation->setUsers($currentUsers);
- $organisationMapper->save($organisation);
-
- $this->_logger->info(
- 'StackiqService: Successfully added user to organization entity',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'organization' => $organization,
- 'totalUsers' => count($currentUsers),
- ]
- );
- } else {
- $this->_logger->info(
- 'StackiqService: User already in organization entity',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'organization' => $organization,
- ]
- );
- }//end if
- } else {
- $this->_logger->warning(
- 'StackiqService: Organization entity not found',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'organization' => $organization,
- ]
- );
- }//end if
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to add user to organization entity',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- 'organization' => $organization,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- } else {
- $this->_logger->warning(
- 'StackiqService: No organisation reference found for contact person',
- [
- 'objectId' => $objectId,
- 'username' => $username,
- ]
- );
- }//end if
- } else {
- $this->_logger->warning(
- 'StackiqService: No username generated for contactpersoon',
- [
- 'objectId' => $objectId,
- 'objectData' => $updatedObjectData,
- ]
- );
- }//end if
- } else {
- $this->_logger->warning(
- 'StackiqService: ContactPersonHandler returned false',
- [
- 'objectId' => $objectId,
- 'processingTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms',
- ]
- );
- }//end if
-
- return $result;
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to process contactpersoon object: ' . $e->getMessage(),
- [
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- 'objectId' => $contactPersonObject->getId() ?? 'unknown',
- 'processingTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms',
- ]
- );
- throw $e;
- }//end try
- }//end processContactpersoon()
-
- /**
- * Processes a contactpersoon object to ensure it has a username
- *
- * If the contactpersoon object doesn't have a username or it's empty,
- * this method will create a user account and set the username property.
- *
- * @param object $contactPersonObject The contactpersoon object to process
- *
- * @return bool True if processing was successful
- * @throws \Exception If processing fails
- */
-
- /**
- * Processes organization without contactpersonen processing
- *
- * @param object $organizationObject The organization object to process.
- *
- * @return bool True if processing was successful.
- * @throws \Exception If processing fails.
- *
- * @deprecated This method is disabled to prevent organization duplication.
- *
- * @spec openspec/specs/method-decomposition/spec.md
- */
- public function processOrganization(object $organizationObject): bool {
- // DISABLED: Organization processing is disabled to prevent duplication.
- $this->_logger->info(
- 'Organization processing is disabled to prevent duplication',
- [
- 'organizationId' => $organizationObject->getId(),
- ]
- );
-
- return false;
- // Disabled: organization processing logic removed.
- }//end processOrganization()
-
- /**
- * Updates user groups based on contactpersoon data
- *
- * @param object $contactPersonObject The contactpersoon object
- * @param string $username The username to update groups for
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function updateUserGroups(object $contactPersonObject, string $username): void {
- // Use the new organization type-based logic instead of old role-based logic.
- $user = $this->_container->get(\OCP\IUserManager::class)->get($username);
- if (empty($user) === false) {
- $contactData = $contactPersonObject->getObject();
- $this->_contactPersonHandler->updateUserGroupsFromContactData($user, $contactData);
- } else {
- $this->_logger->warning('User not found for group update', ['username' => $username]);
- }
- }//end updateUserGroups()
-
- /**
- * Ensures organization has at least one beheerder and manages user hierarchy
- *
- * @param object $contactPersonObject The contactpersoon object
- * @param string $username The username being processed
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function ensureOrganizationBeheerder(object $contactPersonObject, string $username): void {
- // Delegate to hierarchy handler.
- $this->_hierarchyHandler->ensureOrganizationBeheerder($contactPersonObject, $username);
- }//end ensureOrganizationBeheerder()
-
- /**
- * Gets a user's manager
- *
- * @param string $username The username
- *
- * @return string|null The manager's username or null if not set
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function getUserManager(string $username): ?string {
- // Delegate to contact person handler.
- return $this->_contactPersonHandler->getUserManager($username);
- }//end getUserManager()
-
- /**
- * Handles new organization creation - syncs with OpenRegister and processes organization
- *
- * @param object $organizationObject The new organization object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleNewOrganization(object $organizationObject): void {
- try {
- $this->_logger->info(
- 'StackiqService: Handling new organization',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
-
- // First, sync the organization with OpenRegister.
- $syncResult = $this->syncOrganizationWithOpenRegister(organizationObject: $organizationObject);
-
- if ($syncResult === true) {
- $this->_logger->info(
- 'StackiqService: Successfully synced organization with OpenRegister',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
-
- // Update organization references on objects to point to the newly created organization entity.
- $this->updateOrganizationReferences(organizationObject: $organizationObject);
- } else {
- $this->_logger->warning(
- 'StackiqService: Failed to sync organization with OpenRegister',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
- }
-
- // Process the organization (existing functionality) - this creates users.
- $this->processOrganization(organizationObject: $organizationObject);
-
- // Add all admin group users to the organization.
- $objectData = $organizationObject->getObject();
- $organizationUuid = $objectData['id'] ?? $organizationObject->getId();
- $this->addAdminGroupUsersToOrganization(organizationUuid: $organizationUuid);
-
- // Handle ownership assignment for anonymous user registrations AFTER user creation.
- $this->handleOwnershipAssignment(organizationObject: $organizationObject);
-
- // Send welcome email for new organization.
- $this->sendOrganizationWelcomeEmail(organizationObject: $organizationObject);
-
- // If organization is active, send activation email too.
- $objectData = $organizationObject->getObject();
- $assessment = strtolower($objectData['beoordeling'] ?? '');
-
- if ($assessment === 'actief') {
- try {
- $success = $this->_emailService->sendOrganizationActivationEmail($objectData);
- $this->_logger->info(
- 'Organization activation email sent',
- [
- 'objectId' => $organizationObject->getId(),
- 'success' => $success,
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'Failed to send organization activation email: ' . $e->getMessage(),
- [
- 'objectId' => $organizationObject->getId(),
- 'exception' => $e,
- ]
- );
- }
- }
-
- // Process nested contact persons and add their users to the organization entity.
- $contactpersonen = $objectData['contactpersonen'] ?? [];
- if (empty($contactpersonen) === false) {
- $this->_logger->info(
- 'StackiqService: Processing nested contact persons',
- [
- 'objectId' => $organizationObject->getId(),
- 'contactPersonCount' => count($contactpersonen),
- ]
- );
-
- $organizationUuid = $objectData['id'] ?? $organizationObject->getId();
- $objectService = $this->getObjectService();
-
- if (empty($objectService) === false) {
- $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService');
- $voorzieningenConfig = $settingsService->getVoorzieningenConfig();
- $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null;
-
- if ($contactSchemaId === null) {
- $this->_logger->warning('StackiqService: Missing contactpersoon schema configuration');
- return;
- }
-
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- $this->_logger->warning(
- 'StackiqService: OpenRegister OrganisationMapper not available, skipping contact person membership'
- );
- return;
- }
-
- $organisation = $organisationMapper->findByUuid($organizationUuid);
-
- if (empty($organisation) === false) {
- $currentUsers = $organisation->getUsers() ?? [];
- $addedUsers = [];
-
- foreach ($contactpersonen as $contactPersonId) {
- try {
- $contactPersonObject = $objectService->find($contactPersonId);
- $contactData = $contactPersonObject->getObject();
- $email = $contactData['email'] ?? null;
-
- if ($email !== false && in_array($email, $currentUsers) === false) {
- $currentUsers[] = $email;
- $addedUsers[] = $email;
-
- $this->_logger->info(
- 'StackiqService: Added nested contact person user to organization',
- [
- 'objectId' => $organizationObject->getId(),
- 'contactPersonId' => $contactPersonId,
- 'username' => $email,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- }
- } catch (\Exception $e) {
- $this->_logger->warning(
- 'StackiqService: Failed to process nested contact person',
- [
- 'objectId' => $organizationObject->getId(),
- 'contactPersonId' => $contactPersonId,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end foreach
-
- if (empty($addedUsers) === false) {
- $organisation->setUsers($currentUsers);
- $organisationMapper->save($organisation);
-
- $this->_logger->info(
- 'StackiqService: Updated org with nested contact person users',
- [
- 'objectId' => $organizationObject->getId(),
- 'organizationUuid' => $organizationUuid,
- 'addedUsers' => $addedUsers,
- 'totalUsers' => count($currentUsers),
- ]
- );
- }
- }//end if
- }//end if
- }//end if
-
- // Final synchronization: ensure all contact persons associated with this organization are in the users array.
- // This handles cases where contact persons were created separately and not as nested objects.
- $objectData = $organizationObject->getObject();
- $organizationUuid = $objectData['id'] ?? $organizationObject->getId();
- $this->syncContactPersonUsernamesWithOrganization(organizationUuid: $organizationUuid);
-
- $this->_logger->info(
- 'StackiqService: Completed final contact person synchronization for new organization',
- [
- 'objectId' => $organizationObject->getId(),
- 'organizationUuid' => $organizationUuid,
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to handle new organization: ' . $e->getMessage(),
- [
- 'objectId' => $organizationObject->getId(),
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
- }//end handleNewOrganization()
-
- /**
- * Handles organization updates - syncs with OpenRegister and manages user status based on organization status
- *
- * @param object $organizationObject The updated organization object
- * @param object $oldOrganizationObject The previous organization object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleOrganizationUpdate(object $organizationObject, object $oldOrganizationObject): void {
- try {
- $this->_logger->info(
- 'StackiqService: Handling organization update',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
-
- $newData = $organizationObject->getObject();
- $oldData = $oldOrganizationObject->getObject();
-
- // Check both 'beoordeling' and 'status' fields (different schemas use different field names).
- $newAssessment = strtolower($newData['beoordeling'] ?? $newData['status'] ?? '');
- $oldAssessment = strtolower($oldData['beoordeling'] ?? $oldData['status'] ?? '');
-
- // Sync the organization with OpenRegister.
- $syncResult = $this->syncOrganizationWithOpenRegister(organizationObject: $organizationObject);
-
- if ($syncResult === true) {
- $this->_logger->info(
- 'StackiqService: Successfully synced organization with OpenRegister',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
- } else {
- $this->_logger->warning(
- 'StackiqService: Failed to sync organization with OpenRegister',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
- }
-
- // Add all admin group users to the organization (ensure they're always included).
- $organizationUuid = $newData['id'] ?? $organizationObject->getId();
- $this->addAdminGroupUsersToOrganization(organizationUuid: $organizationUuid);
-
- // Check if organization status changed to active.
- if ($newAssessment === 'actief') {
- $becameActive = ($oldAssessment !== 'actief');
-
- $activeMessage = 'Organization is active';
- if ($becameActive === true) {
- $activeMessage = 'Organization became active';
- }
-
- $this->_logger->info(
- $activeMessage,
- [
- 'organizationId' => $organizationObject->getId(),
- 'oldBeoordeling' => $oldAssessment,
- 'newBeoordeling' => $newAssessment,
- 'becameActive' => $becameActive,
- ]
- );
-
- if ($becameActive === true) {
- $organizationUuid = $newData['id'] ?? $organizationObject->getId();
-
- $this->_logger->info(
- 'StackiqService: Organization became active - creating users from contactpersonen',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Process the organization to create users from contactpersonen.
- // This is crucial when an organization is activated for the first time.
- // and contactpersonen were added before activation.
- $this->processOrganization(organizationObject: $organizationObject);
-
- // Activate Stackiq-specific users in this organization.
- $this->activateStackiqUsersForOrganization(organizationUuid: $organizationUuid);
-
- // Send activation email.
- try {
- $success = $this->_emailService->sendOrganizationActivationEmail($newData);
- $this->_logger->info(
- 'Organization activation email sent',
- [
- 'objectId' => $organizationObject->getId(),
- 'success' => $success,
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'Failed to send organization activation email: ' . $e->getMessage(),
- [
- 'objectId' => $organizationObject->getId(),
- 'exception' => $e,
- ]
- );
- }
- }//end if
- }//end if
-
- // Check if organization status changed to inactive.
- if ($newAssessment === 'inactief' || $newAssessment === 'deactief') {
- $becameInactive = ($oldAssessment === 'actief');
-
- $inactiveMessage = 'Organization is inactive';
- if ($becameInactive === true) {
- $inactiveMessage = 'Organization became inactive';
- }
-
- $this->_logger->info(
- $inactiveMessage,
- [
- 'organizationId' => $organizationObject->getId(),
- 'oldBeoordeling' => $oldAssessment,
- 'newBeoordeling' => $newAssessment,
- 'becameInactive' => $becameInactive,
- ]
- );
-
- if ($becameInactive === true) {
- // Deactivate Stackiq-specific users in this organization.
- $organizationUuid = $newData['id'] ?? $organizationObject->getId();
- $this->deactivateStackiqUsersForOrganization(organizationUuid: $organizationUuid);
- }
- }//end if
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to handle organization update: ' . $e->getMessage(),
- [
- 'objectId' => $organizationObject->getId(),
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
- }//end handleOrganizationUpdate()
-
- /**
- * Activates contactpersonen users for an organization.
- *
- * @param string $organizationId The organization ID.
- *
- * @return void
- *
- * @deprecated This method is disabled to prevent organization duplication.
- */
- private function activateContactpersonenForOrganization(string $organizationId): void {
- // DISABLED: Organization handling is disabled to prevent duplication.
- $this->_logger->info(
- 'Organization contactpersonen activation is disabled to prevent duplication',
- [
- 'organizationId' => $organizationId,
- ]
- );
-
- return;
- // Disabled: organization contactpersonen activation logic removed.
- }//end activateContactpersonenForOrganization()
-
- /**
- * Sends welcome email to organization.
- *
- * @param object $organizationObject The organization object.
- *
- * @return void
- *
- * @deprecated This method is disabled to prevent organization duplication.
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function sendOrganizationWelcomeEmail(object $organizationObject): void {
- // DISABLED: Organization handling is disabled to prevent duplication.
- $this->_logger->info(
- 'Organization welcome email sending is disabled to prevent duplication',
- [
- 'organizationId' => $organizationObject->getId(),
- ]
- );
-
- return;
- // Disabled: organization welcome email logic removed.
- }//end sendOrganizationWelcomeEmail()
-
- /**
- * Handles new contact creation
- *
- * @param object $contactObject The contact object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleNewContact(object $contactObject): void {
- // Delegate to contact person handler.
- $this->_contactPersonHandler->handleNewContact($contactObject);
- }//end handleNewContact()
-
- /**
- * Handles new gebruiker creation
- *
- * @param object $userObject The gebruiker object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleNewGebruiker(object $userObject): void {
- // Implementation for handling new gebruiker.
- $this->_logger->info(
- 'Handling new gebruiker',
- [
- 'objectId' => $userObject->getId(),
- ]
- );
- }//end handleNewGebruiker()
-
- /*
- * NO sendGebruikerWelcomeEmail() HERE.
- *
- * Its whole body was a `logger->info('Sending gebruiker welcome email')`
- * — it never sent anything — and no caller reached it:
- * `StackiqEventListener` does not invoke it on the
- * gebruiker-created path. Wiring a method that sends no mail would have
- * bought nothing; implementing one is a feature, not dead-code removal.
- */
-
- /**
- * Handles contact update
- *
- * @param object $contactObject The contact object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleContactUpdate(object $contactObject): void {
- // Delegate to contact person handler.
- $this->_contactPersonHandler->handleContactUpdate($contactObject);
- }//end handleContactUpdate()
-
- /**
- * Handles gebruiker update
- *
- * @param object $userObject The new gebruiker object
- * @param object $oldUserObject The old gebruiker object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleGebruikerUpdate(object $userObject, object $oldUserObject): void {
- // Implementation for handling gebruiker updates.
- $this->_logger->info(
- 'Handling gebruiker update',
- [
- 'objectId' => $userObject->getId(),
- ]
- );
- }//end handleGebruikerUpdate()
-
- /**
- * Handles contact deletion
- *
- * @param object $contactObject The contact object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleContactDeletion(object $contactObject): void {
- // Delegate to contact person handler.
- $this->_contactPersonHandler->handleContactDeletion($contactObject);
- }//end handleContactDeletion()
-
- /**
- * Blocks user for gebruiker
- *
- * @param object $userObject The gebruiker object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function blockUserForGebruiker(object $userObject): void {
- // Implementation for blocking user.
- $this->_logger->info(
- 'Blocking user for gebruiker',
- [
- 'objectId' => $userObject->getId(),
- ]
- );
- }//end blockUserForGebruiker()
-
- /**
- * Temporarily blocks user for gebruiker
- *
- * @param object $userObject The gebruiker object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function temporarilyBlockUserForGebruiker(object $userObject): void {
- // Implementation for temporarily blocking user.
- $this->_logger->info(
- 'Temporarily blocking user for gebruiker',
- [
- 'objectId' => $userObject->getId(),
- ]
- );
- }//end temporarilyBlockUserForGebruiker()
-
- /**
- * Restores user access for gebruiker
- *
- * @param object $userObject The gebruiker object
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function restoreUserAccessForGebruiker(object $userObject): void {
- // Implementation for restoring user access.
- $this->_logger->info(
- 'Restoring user access for gebruiker',
- [
- 'objectId' => $userObject->getId(),
- ]
- );
- }//end restoreUserAccessForGebruiker()
-
- /*
- * NO REVERT-SYNC METHODS HERE.
- *
- * `syncUserWithRevertedContact()` and `updateUserFromRevertedGebruiker()`
- * were the same shape as `sendGebruikerWelcomeEmail()` above: a single
- * `logger->info()` and nothing else, with no caller —
- * `StackiqEventListener` handles `ObjectRevertedEvent` without
- * touching this service. They named a capability (reconcile the Nextcloud
- * user after an object revert) that has never been implemented; a log line
- * is not that capability, and wiring one in would have made the gap
- * invisible instead of removing it.
- */
-
- /**
- * Gets the list of generic user groups
- *
- * @return array Array of generic user groups
- */
- public function getGenericUserGroups(): array {
- return $this->_groupHandler->getGenericUserGroups();
- }//end getGenericUserGroups()
-
- /**
- * Sets the list of generic user groups
- *
- * @param array $groups Array of generic user groups
- *
- * @return void
- */
- public function setGenericUserGroups(array $groups): void {
- $this->_groupHandler->setGenericUserGroups($groups);
- }//end setGenericUserGroups()
-
- /**
- * Ensures all generic user groups exist
- *
- * @return array Array of created/existing groups
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function ensureGenericUserGroupsExist(): array {
- return $this->_groupHandler->ensureGenericUserGroupsExist();
- }//end ensureGenericUserGroupsExist()
-
- /**
- * Gets organizational hierarchy information for a user
- *
- * @param string $username The username to get hierarchy for
- *
- * @return array Array containing hierarchy information
- */
- public function getUserHierarchy(string $username): array {
- return $this->_hierarchyHandler->getUserHierarchy($username);
- }//end getUserHierarchy()
-
- /**
- * Gets complete organizational structure
- *
- * @param string $organizationUuid The organization UUID
- *
- * @return array Array containing organizational structure
- */
- public function getOrganizationStructure(string $organizationUuid): array {
- return $this->_hierarchyHandler->getOrganizationStructure($organizationUuid);
- }//end getOrganizationStructure()
-
- /**
- * Handles contactpersoon updates, particularly role changes
- *
- * @param object $contactPersonObject The updated contactpersoon object
- * @param object $oldContactPersonObject The previous contactpersoon object (optional)
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleContactpersoonUpdate(object $contactPersonObject, ?object $oldContactPersonObject = null): void {
- $startTime = microtime(true);
-
- try {
- $objectId = $contactPersonObject->getId();
- $this->_logger->info(
- 'StackiqService: Starting contactpersoon update handling',
- [
- 'objectId' => $objectId,
- 'hasOldObject' => $oldContactPersonObject !== null,
- 'timestamp' => date('Y-m-d H:i:s'),
- ]
- );
-
- // Get current and old data for comparison.
- $newData = $contactPersonObject->getObject();
- $oldData = [];
- if ($oldContactPersonObject !== null) {
- $oldData = $oldContactPersonObject->getObject();
- }
-
- $newRoles = $newData['roles'] ?? [];
- $oldRoles = $oldData['roles'] ?? [];
-
- $this->_logger->debug(
- 'StackiqService: Comparing roles for contactpersoon update',
- [
- 'objectId' => $objectId,
- 'newRoles' => $newRoles,
- 'oldRoles' => $oldRoles,
- 'newRolesType' => gettype($newRoles),
- 'oldRolesType' => gettype($oldRoles),
- ]
- );
-
- // Ensure both are arrays.
- if (is_array($newRoles) === false) {
- $newRoles = [$newRoles];
- $this->_logger->debug(
- 'StackiqService: Converted newRoles to array',
- [
- 'objectId' => $objectId,
- 'newRoles' => $newRoles,
- ]
- );
- }
-
- if (is_array($oldRoles) === false) {
- $oldRoles = [$oldRoles];
- $this->_logger->debug(
- 'StackiqService: Converted oldRoles to array',
- [
- 'objectId' => $objectId,
- 'oldRoles' => $oldRoles,
- ]
- );
- }
-
- // For updates, we need to handle differently based on whether roles changed.
- if ($newRoles !== $oldRoles) {
- // Roles changed - use role-based group assignment instead of generic group assignment.
- $this->_logger->info(
- 'StackiqService: Roles changed for contactpersoon, using role-based group assignment',
- [
- 'contactpersoonId' => $objectId,
- 'oldRoles' => $oldRoles,
- 'newRoles' => $newRoles,
- 'addedRoles' => array_diff($newRoles, $oldRoles),
- 'removedRoles' => array_diff($oldRoles, $newRoles),
- ]
- );
-
- // Ensure user exists (but don't assign generic groups).
- $username = $newData['username'] ?? '';
- if (empty($username) === true) {
- // Generate username and create user if needed.
- $result = $this->_contactPersonHandler->processContactpersoon($contactPersonObject, true);
- if ($result === true) {
- $updatedData = $contactPersonObject->getObject();
- $username = $updatedData['username'] ?? '';
- }
- }
-
- if (empty($username) === false) {
- $user = $this->_container->get(\OCP\IUserManager::class)->get($username);
- if (empty($user) === false) {
- // Use new organization type-based logic instead of old role-based logic.
- $contactData = $contactPersonObject->getObject();
- $this->_contactPersonHandler->updateUserGroupsFromContactData($user, $contactData);
-
- $this->_logger->info(
- 'StackiqService: Organization type-based group updates completed',
- [
- 'username' => $username,
- 'objectId' => $objectId,
- 'newRoles' => $newRoles,
- ]
- );
- } else {
- $this->_logger->warning(
- 'StackiqService: User not found for role-based group updates',
- [
- 'username' => $username,
- 'objectId' => $objectId,
- ]
- );
- }//end if
- } else {
- $this->_logger->warning(
- 'StackiqService: No username available for role-based group updates',
- [
- 'objectId' => $objectId,
- 'newData' => $newData,
- ]
- );
- }//end if
- } else {
- // No role changes - use standard processing (assigns generic groups).
- $this->_logger->debug(
- 'StackiqService: No role changes, using standard contactpersoon processing',
- [
- 'objectId' => $objectId,
- 'roles' => $newRoles,
- ]
- );
-
- $result = $this->processContactpersoon(contactPersonObject: $contactPersonObject, isUpdate: true);
-
- $this->_logger->info(
- 'StackiqService: Standard contactpersoon processing completed',
- [
- 'objectId' => $objectId,
- 'result' => $result,
- 'processingTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms',
- ]
- );
- }//end if
-
- $this->_logger->info(
- 'StackiqService: Contactpersoon update handling completed',
- [
- 'objectId' => $objectId,
- 'totalProcessingTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms',
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to handle contactpersoon update: ' . $e->getMessage(),
- [
- 'objectId' => $contactPersonObject->getId(),
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- 'processingTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms',
- ]
- );
- }//end try
- }//end handleContactpersoonUpdate()
-
- /**
- * Handles organization deletion - deactivates all users in the organization
- *
- * @param object $organizationObject The organization object being deleted
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function handleOrganizationDeletion(object $organizationObject): void {
- try {
- $this->_logger->info(
- 'StackiqService: Handling organization deletion',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
-
- $objectData = $organizationObject->getObject();
- $organizationUuid = $objectData['id'] ?? $organizationObject->getId();
-
- // Deactivate all users in this organization.
- $this->deactivateUsersForOrganization(organizationUuid: $organizationUuid);
-
- $this->_logger->info(
- 'StackiqService: Successfully handled organization deletion',
- [
- 'organizationId' => $organizationUuid,
- 'timestamp' => date('Y-m-d H:i:s'),
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to handle organization deletion: ' . $e->getMessage(),
- [
- 'objectId' => $organizationObject->getId(),
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
- }//end handleOrganizationDeletion()
-
- /**
- * Syncs organization data with OpenRegister
- *
- * @param object $organizationObject The organization object to sync
- *
- * @return bool True if sync was successful
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function syncOrganizationWithOpenRegister(object $organizationObject): bool {
- try {
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister',
- [
- 'objectId' => $organizationObject->getId(),
- 'objectClass' => get_class($organizationObject),
- ]
- );
-
- $objectData = $organizationObject->getObject();
- $organizationUuid = $objectData['id'] ?? $organizationObject->getId();
-
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_2 - Extracted organization data',
- [
- 'organizationUuid' => $organizationUuid,
- 'objectDataKeys' => array_keys($objectData),
- 'hasId' => isset($objectData['id']) === true,
- ]
- );
-
- // Get OpenRegister OrganisationService for proper organization entity management.
- $this->_logger->info('StackiqService: SYNC_STEP_3 - Getting OrganisationService');
- $organisationService = $this->getOrganisationService();
- if ($organisationService === null) {
- $this->_logger->error(
- 'StackiqService: SYNC_STEP_3 - OpenRegister OrganisationService not available'
- );
- return false;
- }
-
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_3 - OrganisationService retrieved',
- [
- 'serviceClass' => get_class($organisationService),
- ]
- );
-
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_4 - OpenRegister configuration',
- [
- 'organizationUuid' => $organizationUuid,
- 'organizationName' => $objectData['name'] ?? 'Unknown',
- ]
- );
-
- // Check if organization already exists in OpenRegister.
- $this->_logger->info('StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister');
- try {
- $this->_logger->info('StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup');
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- $this->_logger->error(
- 'StackiqService: OpenRegister OrganisationMapper not available, cannot sync organization'
- );
- return false;
- }
-
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_5B - Calling findByUuid',
- [
- 'uuid' => $organizationUuid,
- ]
- );
- $existingOrganisation = $organisationMapper->findByUuid($organizationUuid);
-
- // Organization exists - update it.
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating',
- [
- 'organizationId' => $organizationUuid,
- 'existingOrganisationClass' => get_class($existingOrganisation),
- ]
- );
-
- // Map status from Stackiq to OpenRegister.
- $this->_logger->info('StackiqService: SYNC_STEP_7 - Mapping organization data');
- $mappedData = $this->mapOrganizationDataForOpenRegister(objectData: $objectData);
-
- // Update the organization using OrganisationService.
- $updatedOrganisation = $this->updateOrganisationInOpenRegister(
- organisationService: $organisationService,
- existingOrganisation: $existingOrganisation,
- mappedData: $mappedData
- );
-
- $this->_logger->info(
- 'StackiqService: Successfully updated organization in OpenRegister',
- [
- 'organizationId' => $organizationUuid,
- 'openRegisterId' => $updatedOrganisation->getUuid(),
- ]
- );
-
- return true;
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- // Organization doesn't exist - create it.
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating',
- [
- 'organizationId' => $organizationUuid,
- 'exception' => $e->getMessage(),
- ]
- );
-
- // Map status from Stackiq to OpenRegister.
- $this->_logger->info('StackiqService: SYNC_STEP_9 - Mapping organization data for creation');
- $mappedData = $this->mapOrganizationDataForOpenRegister(objectData: $objectData);
-
- // Create the organization using OrganisationService.
- $this->_logger->info('StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister');
- $createdOrganisation = $this->createOrganisationInOpenRegisterInternal(
- organisationService: $organisationService,
- mappedData: $mappedData,
- organizationUuid: $organizationUuid
- );
-
- $this->_logger->info(
- 'StackiqService: SYNC_STEP_11 - Successfully created organization in OpenRegister',
- [
- 'organizationId' => $organizationUuid,
- 'openRegisterId' => $createdOrganisation->getUuid(),
- 'createdOrganisationClass' => get_class($createdOrganisation),
- ]
- );
-
- return true;
- }//end try
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to sync organization with OpenRegister: ' . $e->getMessage(),
- [
- 'objectId' => $organizationObject->getId(),
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- return false;
- }//end try
- }//end syncOrganizationWithOpenRegister()
-
- /**
- * Public wrapper for creating organization in OpenRegister (used by background job)
- *
- * @param array $objectData The organization object data
- *
- * @return object|null The created organisation entity or null on failure
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function createOrganisationInOpenRegister(array $objectData): ?object {
- try {
- $organizationUuid = $objectData['id'] ?? null;
- if ($organizationUuid === null) {
- $this->_logger->error('StackiqService: No organization UUID provided for creation');
- return null;
- }
-
- // Map the data.
- $mappedData = [
- 'name' => $objectData['name'] ?? 'Unknown',
- 'type' => $objectData['type'] ?? '',
- 'website' => $objectData['website'] ?? '',
- 'active' => $this->mapStatus(status: $objectData['beoordeling'] ?? 'actief'),
- 'contactpersonen' => $objectData['contactpersonen'] ?? [],
- 'participants' => $objectData['participants'] ?? [],
- ];
-
- // Get organisation service.
- $organisationService = $this->_container->get('OCA\OpenRegister\Service\OrganisationService');
-
- return $this->createOrganisationInOpenRegisterInternal(
- organisationService: $organisationService,
- mappedData: $mappedData,
- organizationUuid: $organizationUuid
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error in public createOrganisationInOpenRegister',
- [
- 'error' => $e->getMessage(),
- 'objectData' => $objectData,
- ]
- );
- return null;
- }//end try
- }//end createOrganisationInOpenRegister()
-
- /**
- * Map status from Software Catalog to OpenRegister format
- *
- * @param string $status The status from Software Catalog
- *
- * @return bool The mapped active status for OpenRegister
- */
- private function mapStatus(string $status): bool {
- switch (strtolower($status)) {
- case 'actief':
- return true;
- case 'inactief':
- return false;
- default:
- return true;
- // Default to active.
- }
- }//end mapStatus()
-
- /**
- * Creates an organization in OpenRegister using OrganisationService
- *
- * @param \OCA\OpenRegister\Service\OrganisationService $organisationService The OpenRegister organisation service
- * @param array $mappedData The mapped organization data
- * @param string $organizationUuid The organization UUID to use
- *
- * @return \OCA\OpenRegister\Db\Organisation The created organization
- */
- private function createOrganisationInOpenRegisterInternal(
- \OCA\OpenRegister\Service\OrganisationService $organisationService,
- array $mappedData,
- string $organizationUuid,
- ): \OCA\OpenRegister\Db\Organisation {
- $this->_logger->info(
- 'StackiqService: STEP 1 - Starting createOrganisationInOpenRegister',
- [
- 'organizationUuid' => $organizationUuid,
- 'name' => $mappedData['name'] ?? 'Unknown',
- 'mappedDataKeys' => array_keys($mappedData),
- ]
- );
-
- // Check if we're in an anonymous context (no logged-in user).
- $userSession = $this->_userSession;
- $currentUser = $userSession->getUser();
-
- $currentUserValue = 'null';
- if ($currentUser !== null) {
- $currentUserValue = $currentUser->getUID();
- }
-
- $this->_logger->info(
- 'StackiqService: STEP 2 - Checking user context',
- [
- // Always true: $userSession is an injected, non-nullable IUserSession.
- 'hasUserSession' => true,
- 'currentUser' => $currentUserValue,
- 'isAnonymous' => $currentUser === null,
- ]
- );
-
- if ($currentUser === null) {
- $this->_logger->info(
- 'StackiqService: STEP 3A - Anonymous path: No user, creating org directly via mapper',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Keep the original UUID format - no conversion needed.
- $this->_logger->info(
- 'StackiqService: STEP 3B - Using original UUID format for OpenRegister (anonymous)',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Create organization directly via mapper to avoid user context requirements.
- $this->_logger->info('StackiqService: STEP 3C - Getting OrganisationMapper from container');
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- // This method's return type is non-nullable and its caller already
- // holds an OpenRegister OrganisationService, so "unavailable" here
- // escapes exactly as the raw container exception used to.
- throw new RuntimeException('OpenRegister OrganisationMapper is not available');
- }
-
- $this->_logger->info(
- 'StackiqService: STEP 3D - OrganisationMapper retrieved',
- [
- 'mapperClass' => get_class($organisationMapper),
- ]
- );
-
- // Create a new Organisation entity.
- $this->_logger->info('StackiqService: STEP 3E - Creating new Organisation entity');
- $organisation = new \OCA\OpenRegister\Db\Organisation();
-
- $this->_logger->info(
- 'StackiqService: STEP 3F - Setting organisation properties',
- [
- 'name' => $mappedData['name'] ?? 'Unknown Organization',
- 'description' => $mappedData['website'] ?? '',
- 'uuid' => $organizationUuid,
- ]
- );
-
- // Collect all contact person usernames for this organization.
- $contactPersonUsernames = $this->collectContactPersonUsernames(
- organizationUuid: $organizationUuid,
- objectData: $mappedData
- );
-
- // Start with admin user and add all contact person usernames.
- $allUsernames = array_merge(['admin'], $contactPersonUsernames);
- $allUsernames = array_unique($allUsernames);
-
- $this->_logger->info(
- 'StackiqService: STEP 3F_2 - Collected usernames for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'totalUsernames' => count($allUsernames),
- 'usernames' => $allUsernames,
- ]
- );
-
- $organisation->setName($mappedData['name'] ?? 'Unknown Organization');
- $organisation->setDescription($mappedData['website'] ?? '');
- // Use website as description.
- $organisation->setUuid($organizationUuid);
- $organisation->setUsers($allUsernames);
- $organisation->setOwner('admin');
- // Set admin as owner for anonymous registrations.
- $organisation->setActive($mappedData['active'] ?? true);
- // Set active status based on organization beoordeling.
- // Debug: Check if UUID was set correctly.
- $this->_logger->info(
- 'StackiqService: STEP 3G - Debug - UUID before save',
- [
- 'setUuid' => $organizationUuid,
- 'getUuid' => $organisation->getUuid(),
- 'uuidMatches' => $organisation->getUuid() === $organizationUuid,
- 'organisationClass' => get_class($organisation),
- ]
- );
-
- // Save the organization.
- $this->_logger->info('StackiqService: STEP 3H - Calling organisationMapper->save()');
- try {
- $savedOrganisation = $organisationMapper->save($organisation);
- $this->_logger->info(
- 'StackiqService: STEP 3I - organisationMapper->save() completed'
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: STEP 3I - organisationMapper->save() failed',
- [
- 'error' => $e->getMessage(),
- 'errorClass' => get_class($e),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- throw $e;
- }
-
- $this->_logger->info(
- 'StackiqService: Successfully created organization in OpenRegister via mapper',
- [
- 'organizationUuid' => $organizationUuid,
- 'openRegisterId' => $savedOrganisation->getUuid(),
- 'savedUuid' => $savedOrganisation->getUuid(),
- 'expectedUuid' => $organizationUuid,
- ]
- );
-
- // Verify the UUID was preserved.
- if ($savedOrganisation->getUuid() !== $organizationUuid) {
- $this->_logger->warning(
- 'StackiqService: UUID mismatch after saving organization',
- [
- 'expectedUuid' => $organizationUuid,
- 'actualUuid' => $savedOrganisation->getUuid(),
- ]
- );
- }
- }//end if
-
- $this->_logger->info(
- 'StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper',
- [
- 'organizationUuid' => $organizationUuid,
- 'currentUser' => $currentUser->getUID(),
- ]
- );
-
- // Keep the original UUID format - no conversion needed.
- $this->_logger->info(
- 'StackiqService: STEP 4B - Using original UUID format for OpenRegister',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Create organization directly via mapper to avoid service issues.
- $this->_logger->info('StackiqService: STEP 4C - Getting OrganisationMapper from container');
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- // Non-nullable return type, same reasoning as the anonymous branch above.
- throw new RuntimeException('OpenRegister OrganisationMapper is not available');
- }
-
- $this->_logger->info(
- 'StackiqService: STEP 4D - OrganisationMapper retrieved',
- [
- 'mapperClass' => get_class($organisationMapper),
- ]
- );
-
- // Debug: Check UUID before creating.
- // Collect all contact person usernames for this organization.
- $contactPersonUsernames = $this->collectContactPersonUsernames(
- organizationUuid: $organizationUuid,
- objectData: $mappedData
- );
-
- // Start with current user and add all contact person usernames.
- $allUsernames = array_merge([$currentUser->getUID()], $contactPersonUsernames);
- $allUsernames = array_unique($allUsernames);
-
- $this->_logger->info(
- 'StackiqService: STEP 4E - Debug - UUID before createWithUuid',
- [
- 'organizationUuid' => $organizationUuid,
- 'uuidLength' => strlen($organizationUuid),
- 'uuidIsEmpty' => empty($organizationUuid) === true,
- 'name' => $mappedData['name'] ?? 'Unknown Organization',
- 'description' => $mappedData['website'] ?? '',
- 'owner' => $currentUser->getUID(),
- 'users' => $allUsernames,
- 'contactPersonUsernames' => $contactPersonUsernames,
- ]
- );
-
- $this->_logger->info('StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()');
- try {
- // Debug: Log the exact parameters being passed.
- $this->_logger->info(
- 'StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid',
- [
- 'name' => $mappedData['name'] ?? 'Unknown Organization',
- 'description' => $mappedData['website'] ?? '',
- 'uuid' => $organizationUuid,
- 'owner' => $currentUser->getUID(),
- 'users' => $allUsernames,
- 'isDefault' => false,
- 'uuidLength' => strlen($organizationUuid),
- 'uuidIsEmpty' => empty($organizationUuid) === true,
- ]
- );
-
- $organisation = $organisationMapper->createWithUuid(
- $mappedData['name'] ?? 'Unknown Organization',
- $mappedData['website'] ?? '',
- // Use website as description.
- $organizationUuid,
- // Pass the original UUID.
- $currentUser->getUID(),
- // Set current user as owner.
- $allUsernames,
- // Add all users including contact persons.
- false
- // Not default.
- );
- $this->_logger->info(
- 'StackiqService: STEP 4G - organisationMapper->createWithUuid() completed'
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: STEP 4G - organisationMapper->createWithUuid() failed',
- [
- 'error' => $e->getMessage(),
- 'errorClass' => get_class($e),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- throw $e;
- }//end try
-
- // Note: OpenRegister Organisation entity doesn't have status or type fields.
- // These are managed in the Stackiq object, not in the OpenRegister organisation.
- $this->_logger->info(
- 'StackiqService: Successfully created organization in OpenRegister via service',
- [
- 'organizationUuid' => $organizationUuid,
- 'openRegisterId' => $organisation->getUuid(),
- 'savedUuid' => $organisation->getUuid(),
- 'expectedUuid' => $organizationUuid,
- ]
- );
-
- return $organisation;
- }//end createOrganisationInOpenRegisterInternal()
-
- /**
- * Updates an organization in OpenRegister using OrganisationService
- *
- * @param \OCA\OpenRegister\Service\OrganisationService $organisationService The OpenRegister organisation service
- * @param \OCA\OpenRegister\Db\Organisation $existingOrganisation The existing organization
- * @param array $mappedData The mapped organization data
- *
- * @return \OCA\OpenRegister\Db\Organisation The updated organization
- */
- private function updateOrganisationInOpenRegister(
- \OCA\OpenRegister\Service\OrganisationService $organisationService,
- \OCA\OpenRegister\Db\Organisation $existingOrganisation,
- array $mappedData,
- ): \OCA\OpenRegister\Db\Organisation {
- $this->_logger->info(
- 'StackiqService: Updating organization in OpenRegister',
- [
- 'organizationUuid' => $existingOrganisation->getUuid(),
- 'name' => $mappedData['name'] ?? 'Unknown',
- ]
- );
-
- // Update organization fields (only those that exist on the Organisation entity).
- if (isset($mappedData['name']) === true) {
- $existingOrganisation->setName($mappedData['name']);
- }
-
- if (isset($mappedData['description']) === true) {
- $existingOrganisation->setDescription($mappedData['description']);
- }
-
- // Note: OpenRegister Organisation entity doesn't have status or type fields.
- // These are managed in the Stackiq object, not in the OpenRegister organisation.
- // Save the updated organization.
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- // Non-nullable return type; the caller already holds an OpenRegister
- // OrganisationService, so this escapes as the container lookup used to.
- throw new RuntimeException('OpenRegister OrganisationMapper is not available');
- }
-
- $updatedOrganisation = $organisationMapper->save($existingOrganisation);
-
- $this->_logger->info(
- 'StackiqService: Successfully updated organization in OpenRegister',
- [
- 'organizationUuid' => $existingOrganisation->getUuid(),
- 'openRegisterId' => $updatedOrganisation->getUuid(),
- ]
- );
-
- return $updatedOrganisation;
- }//end updateOrganisationInOpenRegister()
-
- /**
- * Collects all contact person usernames associated with an organization
- *
- * @param string $organizationUuid The organization UUID
- * @param array $objectData The organization object data (for nested contact persons)
- *
- * @return array Array of usernames
- */
- private function collectContactPersonUsernames(string $organizationUuid, array $objectData = []): array {
- $usernames = [];
-
- // Focus on nested contact persons in the organization object data.
- // These are available immediately when the organization is created.
- $nestedContactPersons = $objectData['contactpersonen'] ?? [];
- $this->_logger->info(
- 'StackiqService: Processing nested contact persons',
- [
- 'organizationUuid' => $organizationUuid,
- 'nestedContactPersonCount' => count($nestedContactPersons),
- ]
- );
-
- foreach ($nestedContactPersons as $contactPerson) {
- if (is_array($contactPerson) === true && isset($contactPerson['email']) === true) {
- $usernames[] = $contactPerson['email'];
- $this->_logger->info(
- 'StackiqService: Added nested contact person username',
- [
- 'username' => $contactPerson['email'],
- 'contactPersonData' => $contactPerson,
- ]
- );
- }
- }
-
- // Also try to find existing contact persons by their organisatie field.
- // This is useful for updates or when contact persons were created separately.
- $objectService = $this->getObjectService();
- if (empty($objectService) === false) {
- $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService');
- $voorzieningenConfig = $settingsService->getVoorzieningenConfig();
- $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null;
-
- if ($contactSchemaId === null) {
- $this->_logger->warning(
- 'StackiqService: Missing contactpersoon schema config for username extraction'
- );
- return $usernames;
- }
-
- try {
- // Try multiple approaches to find contact persons.
- $contactPersons = [];
-
- // Approach 1: Find by organisatie field.
- try {
- $contactPersons = $objectService->findAll(
- [
- 'filters' => [
- 'register' => $objectData['register'] ?? '6',
- 'schema' => $contactSchemaId,
- 'organization' => $organizationUuid,
- ],
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->info(
- 'StackiqService: Approach 1 failed, trying approach 2',
- [
- 'organizationUuid' => $organizationUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }
-
- // Approach 2: If approach 1 fails, try to find all contact persons and filter by organisatie.
- if (empty($contactPersons) === true) {
- try {
- $allContactPersons = $objectService->findAll(
- [
- 'filters' => [
- 'register' => $objectData['register'] ?? '6',
- 'schema' => $contactSchemaId,
- ],
- ]
- );
-
- foreach ($allContactPersons as $contactPerson) {
- $contactData = $contactPerson->getObject();
- $contactOrganisation = $contactData['organization'] ?? null;
- if ($contactOrganisation === $organizationUuid) {
- $contactPersons[] = $contactPerson;
- }
- }
- } catch (\Exception $e) {
- $this->_logger->info(
- 'StackiqService: Approach 2 also failed',
- [
- 'organizationUuid' => $organizationUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end if
-
- $this->_logger->info(
- 'StackiqService: Found existing contact persons for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'contactPersonCount' => count($contactPersons),
- 'contactPersonIds' => array_map(
- function ($cp) {
- return $cp->getId();
- },
- $contactPersons
- ),
- ]
- );
-
- foreach ($contactPersons as $contactPerson) {
- $contactData = $contactPerson->getObject();
- $email = $contactData['email'] ?? null;
- if (empty($email) === false) {
- $usernames[] = $email;
- $this->_logger->info(
- 'StackiqService: Added existing contact person username',
- [
- 'username' => $email,
- 'contactPersonId' => $contactPerson->getId(),
- ]
- );
- }
- }
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error collecting existing contact person usernames',
- [
- 'organizationUuid' => $organizationUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end if
-
- // Remove duplicates and return.
- $uniqueUsernames = array_unique($usernames);
- $this->_logger->info(
- 'StackiqService: Collected contact person usernames',
- [
- 'organizationUuid' => $organizationUuid,
- 'totalUsernames' => count($uniqueUsernames),
- 'usernames' => $uniqueUsernames,
- ]
- );
-
- return $uniqueUsernames;
- }//end collectContactPersonUsernames()
-
- /**
- * Maps organization data from Stackiq format to OpenRegister format
- *
- * @param array $objectData The organization data from Stackiq
- *
- * @return array The mapped data for OpenRegister
- */
- private function mapOrganizationDataForOpenRegister(array $objectData): array {
- $mappedData = [
- 'name' => $objectData['name'] ?? '',
- 'type' => $objectData['type'] ?? '',
- 'website' => $objectData['website'] ?? '',
- 'active' => false,
- // Default to inactive for new organizations.
- 'contactpersonen' => [],
- 'participants' => [],
- ];
-
- // Map status from Stackiq to OpenRegister.
- $assessment = strtolower($objectData['beoordeling'] ?? '');
- if ($assessment === 'actief') {
- $mappedData['active'] = true;
- } elseif ($assessment === 'inactief' || $assessment === 'deactief') {
- $mappedData['active'] = false;
- }
-
- // Map other fields if they exist.
- if (isset($objectData['adres']) === true) {
- $mappedData['adres'] = $objectData['adres'];
- }
-
- if (isset($objectData['postcode']) === true) {
- $mappedData['postcode'] = $objectData['postcode'];
- }
-
- if (isset($objectData['plaats']) === true) {
- $mappedData['plaats'] = $objectData['plaats'];
- }
-
- if (isset($objectData['telefoon']) === true) {
- $mappedData['telefoon'] = $objectData['telefoon'];
- }
-
- if (isset($objectData['email']) === true) {
- $mappedData['email'] = $objectData['email'];
- }
-
- return $mappedData;
- }//end mapOrganizationDataForOpenRegister()
-
- /**
- * Activates all users in an organization when the organization becomes active
- *
- * @param string $organizationUuid The organization UUID
- *
- * @return void
- */
- private function activateUsersForOrganization(string $organizationUuid): void {
- try {
- $this->_logger->info(
- 'StackiqService: Activating users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- $this->_logger->error('StackiqService: OpenRegister ObjectService not available');
- return;
- }
-
- // Get all contactpersonen for this organization.
- $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'contactPerson', logContext: 'contactpersonen');
- if ($ctx === null) {
- return;
- }
-
- $registerId = $ctx['registerId'];
- $contactPersonSchemaId = $ctx['schemaId'];
-
- // The findAll() signature is ($config, bool $_rbac, bool $_multitenancy).
- // Register and schema belong inside the config's filters, as the
- // sibling call above does -- passed positionally they landed on the
- // two booleans, so this ran unscoped across every register with
- // $_rbac set to a register id.
- $contactpersonen = $objectService->findAll(
- [
- 'filters' => [
- 'register' => $registerId,
- 'schema' => $contactPersonSchemaId,
- 'organisation' => $organizationUuid,
- ],
- ]
- );
-
- $userManager = $this->_container->get(\OCP\IUserManager::class);
- $activatedCount = 0;
-
- foreach ($contactpersonen as $contactPerson) {
- $contactData = $contactPerson->getObject();
- $username = $contactData['username'] ?? '';
-
- if (empty($username) === false) {
- $user = $userManager->get($username);
- if ($user !== null && $user->isEnabled() === false) {
- $user->setEnabled(true);
- $activatedCount++;
-
- $this->_logger->info(
- 'StackiqService: Activated user for organization',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- }
- }
- }
-
- $this->_logger->info(
- 'StackiqService: Completed user activation for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'totalContactpersonen' => count($contactpersonen),
- 'activatedUsers' => $activatedCount,
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to activate users for organization: ' . $e->getMessage(),
- [
- 'organizationUuid' => $organizationUuid,
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
- }//end activateUsersForOrganization()
-
- /**
- * Deactivates all users in an organization when the organization becomes inactive
- *
- * @param string $organizationUuid The organization UUID
- *
- * @return void
- */
- private function deactivateUsersForOrganization(string $organizationUuid): void {
- try {
- $this->_logger->info(
- 'StackiqService: Deactivating users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- $this->_logger->error('StackiqService: OpenRegister ObjectService not available');
- return;
- }
-
- // Get all contactpersonen for this organization.
- $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'contactPerson', logContext: 'contactpersonen');
- if ($ctx === null) {
- return;
- }
-
- $registerId = $ctx['registerId'];
- $contactPersonSchemaId = $ctx['schemaId'];
-
- // The findAll() signature is ($config, bool $_rbac, bool $_multitenancy).
- // Register and schema belong inside the config's filters, as the
- // sibling call above does -- passed positionally they landed on the
- // two booleans, so this ran unscoped across every register with
- // $_rbac set to a register id.
- $contactpersonen = $objectService->findAll(
- [
- 'filters' => [
- 'register' => $registerId,
- 'schema' => $contactPersonSchemaId,
- 'organisation' => $organizationUuid,
- ],
- ]
- );
-
- $userManager = $this->_container->get(\OCP\IUserManager::class);
- $deactivatedCount = 0;
-
- foreach ($contactpersonen as $contactPerson) {
- $contactData = $contactPerson->getObject();
- $username = $contactData['username'] ?? '';
-
- if (empty($username) === false) {
- $user = $userManager->get($username);
- if ($user !== null && $user->isEnabled() === true) {
- $user->setEnabled(false);
- $deactivatedCount++;
-
- $this->_logger->info(
- 'StackiqService: Deactivated user for organization',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- }
- }
- }
-
- $this->_logger->info(
- 'StackiqService: Completed user deactivation for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'totalContactpersonen' => count($contactpersonen),
- 'deactivatedUsers' => $deactivatedCount,
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to deactivate users for organization: ' . $e->getMessage(),
- [
- 'organizationUuid' => $organizationUuid,
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
- }//end deactivateUsersForOrganization()
-
- /**
- * Activates Stackiq-specific users for an organization
- * Only affects users from contactpersoon objects, not admin group users
- *
- * @param string $organizationUuid The organization UUID
- *
- * @return void
- */
- private function activateStackiqUsersForOrganization(string $organizationUuid): void {
- try {
- $this->_logger->info(
- 'StackiqService: Activating Stackiq users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Get Stackiq-specific users (from contactpersonen).
- $softwareCatalogUsers = $this->getStackiqUsersForOrganization(organizationUuid: $organizationUuid);
-
- if (empty($softwareCatalogUsers) === true) {
- $this->_logger->info(
- 'StackiqService: No Stackiq users found for organization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }
-
- $this->_logger->info(
- 'StackiqService: Found Stackiq users to activate',
- [
- 'organizationUuid' => $organizationUuid,
- 'userCount' => count($softwareCatalogUsers),
- 'users' => $softwareCatalogUsers,
- ]
- );
-
- // Get the user manager.
- $userManager = $this->_userManager;
- $activatedUsers = [];
- $failedUsers = [];
-
- foreach ($softwareCatalogUsers as $username) {
- try {
- $user = $userManager->get($username);
- if ($user !== null && $user->isEnabled() === false) {
- $user->setEnabled(true);
- $activatedUsers[] = $username;
- $this->_logger->debug(
- 'StackiqService: Activated Stackiq user',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- ]
- );
- } elseif ($user !== null && $user->isEnabled() === true) {
- $this->_logger->debug(
- 'StackiqService: Stackiq user already active',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- ]
- );
- } else {
- $failedUsers[] = $username;
- $this->_logger->warning(
- 'StackiqService: Stackiq user not found',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- ]
- );
- }//end if
- } catch (\Exception $e) {
- $failedUsers[] = $username;
- $this->_logger->error(
- 'StackiqService: Failed to activate Stackiq user',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end foreach
-
- $this->_logger->info(
- 'StackiqService: Stackiq user activation complete',
- [
- 'organizationUuid' => $organizationUuid,
- 'activatedUsers' => $activatedUsers,
- 'failedUsers' => $failedUsers,
- 'totalProcessed' => count($softwareCatalogUsers),
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error activating Stackiq users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end activateStackiqUsersForOrganization()
-
- /**
- * Deactivates Stackiq-specific users for an organization
- * Only affects users from contactpersoon objects, not admin group users
- *
- * @param string $organizationUuid The organization UUID
- *
- * @return void
- */
- private function deactivateStackiqUsersForOrganization(string $organizationUuid): void {
- try {
- $this->_logger->info(
- 'StackiqService: Deactivating Stackiq users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Get Stackiq-specific users (from contactpersonen).
- $softwareCatalogUsers = $this->getStackiqUsersForOrganization(organizationUuid: $organizationUuid);
-
- if (empty($softwareCatalogUsers) === true) {
- $this->_logger->info(
- 'StackiqService: No Stackiq users found for organization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }
-
- $this->_logger->info(
- 'StackiqService: Found Stackiq users to deactivate',
- [
- 'organizationUuid' => $organizationUuid,
- 'userCount' => count($softwareCatalogUsers),
- 'users' => $softwareCatalogUsers,
- ]
- );
-
- // Get the user manager.
- $userManager = $this->_userManager;
- $deactivatedUsers = [];
- $failedUsers = [];
-
- foreach ($softwareCatalogUsers as $username) {
- try {
- $user = $userManager->get($username);
- if ($user !== null && $user->isEnabled() === true) {
- $user->setEnabled(false);
- $deactivatedUsers[] = $username;
- $this->_logger->debug(
- 'StackiqService: Deactivated Stackiq user',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- ]
- );
- } elseif ($user !== null && $user->isEnabled() === false) {
- $this->_logger->debug(
- 'StackiqService: Stackiq user already inactive',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- ]
- );
- } else {
- $failedUsers[] = $username;
- $this->_logger->warning(
- 'StackiqService: Stackiq user not found',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- ]
- );
- }//end if
- } catch (\Exception $e) {
- $failedUsers[] = $username;
- $this->_logger->error(
- 'StackiqService: Failed to deactivate Stackiq user',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end foreach
-
- $this->_logger->info(
- 'StackiqService: Stackiq user deactivation complete',
- [
- 'organizationUuid' => $organizationUuid,
- 'deactivatedUsers' => $deactivatedUsers,
- 'failedUsers' => $failedUsers,
- 'totalProcessed' => count($softwareCatalogUsers),
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error deactivating Stackiq users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end deactivateStackiqUsersForOrganization()
-
- /**
- * Gets Stackiq-specific users for an organization
- * These are users from contactpersoon objects, excluding admin group users
- *
- * @param string $organizationUuid The organization UUID
- *
- * @return array Array of usernames
- */
- private function getStackiqUsersForOrganization(string $organizationUuid): array {
- try {
- $this->_logger->debug(
- 'StackiqService: Getting Stackiq users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Get the object service to find contactpersonen.
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- $this->_logger->error(
- 'StackiqService: ObjectService not available for getting Stackiq users'
- );
- return [];
- }
-
- // Find all contactpersonen for this organization.
- $contactpersonen = $objectService->findAll(
- [
- 'filters' => [
- 'register' => 6,
- // Voorzieningen register.
- 'schema' => 38,
- // Contactpersoon schema.
- ],
- ]
- );
-
- $softwareCatalogUsers = [];
- $adminGroupUsers = $this->getAdminGroupUsernames();
-
- foreach ($contactpersonen as $contactPersonObject) {
- $contactData = $contactPersonObject->getObject();
- $contactOrganisation = $contactData['organization'] ?? null;
-
- // Check if this contactpersoon belongs to our organization.
- if ($contactOrganisation === $organizationUuid) {
- // Extract username from contactpersoon object data.
- $contactData = $contactPersonObject->getObject();
- $username = $contactData['username'] ?? null;
-
- if ($username !== false && in_array($username, $adminGroupUsers) === false) {
- $softwareCatalogUsers[] = $username;
- $this->_logger->debug(
- 'StackiqService: Found Stackiq user',
- [
- 'organizationUuid' => $organizationUuid,
- 'username' => $username,
- 'contactpersoonId' => $contactPersonObject->getId(),
- ]
- );
- }
- }
- }//end foreach
-
- $this->_logger->info(
- 'StackiqService: Found Stackiq users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'userCount' => count($softwareCatalogUsers),
- 'users' => $softwareCatalogUsers,
- ]
- );
-
- return $softwareCatalogUsers;
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error getting Stackiq users for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'error' => $e->getMessage(),
- ]
- );
- return [];
- }//end try
- }//end getStackiqUsersForOrganization()
-
- /**
- * Gets all usernames from the admin group
- *
- * @return array Array of admin usernames
- */
- private function getAdminGroupUsernames(): array {
- try {
- $groupManager = $this->_groupManager;
- $adminGroup = $groupManager->get('admin');
-
- if ($adminGroup === null) {
- $this->_logger->warning('StackiqService: Admin group not found');
- return [];
- }
-
- $adminUsers = $adminGroup->getUsers();
- $adminUsernames = [];
-
- foreach ($adminUsers as $adminUser) {
- $adminUsernames[] = $adminUser->getUID();
- }
-
- $this->_logger->debug(
- 'StackiqService: Found admin group users',
- [
- 'adminUserCount' => count($adminUsernames),
- 'adminUsers' => $adminUsernames,
- ]
- );
-
- return $adminUsernames;
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error getting admin group usernames',
- [
- 'error' => $e->getMessage(),
- ]
- );
- return [];
- }//end try
- }//end getAdminGroupUsernames()
-
- /**
- * Adds all users from the admin group to the organization entity
- *
- * @param string $organizationUuid The organization UUID
- *
- * @return void
- */
- private function addAdminGroupUsersToOrganization(string $organizationUuid): void {
- try {
- $this->_logger->info(
- 'StackiqService: Adding admin group users to organization entity',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Get the group manager to access admin group users.
- $groupManager = $this->_groupManager;
- $adminGroup = $groupManager->get('admin');
-
- if ($adminGroup === null) {
- $this->_logger->warning('StackiqService: Admin group not found');
- return;
- }
-
- $adminUsers = $adminGroup->getUsers();
- $this->_logger->info(
- 'StackiqService: Found admin group users',
- [
- 'organizationUuid' => $organizationUuid,
- 'adminUserCount' => count($adminUsers),
- ]
- );
-
- // Get the organization entity (not object) to update its users list.
- $organisationMapper = $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper');
- if ($organisationMapper === null) {
- $this->_logger->error('StackiqService: OrganisationMapper not available for adding admin users');
- return;
- }
-
- // Find the organization entity by UUID.
- $this->_logger->info(
- 'StackiqService: Searching for organization entity',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- try {
- $targetOrganisation = $organisationMapper->findByUuid($organizationUuid);
-
- $this->_logger->info(
- 'StackiqService: Found target organization entity',
- [
- 'organizationUuid' => $organizationUuid,
- 'entityId' => $targetOrganisation->getId(),
- ]
- );
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- $this->_logger->warning(
- 'StackiqService: Organization entity not found for adding admin users',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }
-
- // Get current users list from the entity.
- $currentUsers = $targetOrganisation->getUsers() ?? [];
-
- $this->_logger->info(
- 'StackiqService: Current organization entity users',
- [
- 'organizationUuid' => $organizationUuid,
- 'currentUsers' => $currentUsers,
- 'currentUserCount' => count($currentUsers),
- ]
- );
-
- // Add admin users to the list.
- $updatedUsers = $currentUsers;
- $addedUsers = [];
- foreach ($adminUsers as $adminUser) {
- $adminUsername = $adminUser->getUID();
- if (in_array($adminUsername, $updatedUsers) === false) {
- $updatedUsers[] = $adminUsername;
- $addedUsers[] = $adminUsername;
- $this->_logger->debug(
- 'StackiqService: Added admin user to organization entity',
- [
- 'organizationUuid' => $organizationUuid,
- 'adminUsername' => $adminUsername,
- ]
- );
- }
- }
-
- $this->_logger->info(
- 'StackiqService: Admin users processing complete',
- [
- 'organizationUuid' => $organizationUuid,
- 'addedUsers' => $addedUsers,
- 'totalUsersAfterUpdate' => count($updatedUsers),
- ]
- );
-
- // Update the organization entity with the new users list.
- if (count($updatedUsers) > count($currentUsers)) {
- $this->_logger->info(
- 'StackiqService: Updating organization entity with new users',
- [
- 'organizationUuid' => $organizationUuid,
- 'entityId' => $targetOrganisation->getId(),
- 'usersToAdd' => count($updatedUsers) - count($currentUsers),
- ]
- );
-
- // Set the updated users list on the entity.
- $targetOrganisation->setUsers($updatedUsers);
-
- $this->_logger->info(
- 'StackiqService: Saving updated organization entity',
- [
- 'organizationUuid' => $organizationUuid,
- 'entityId' => $targetOrganisation->getId(),
- 'newUserCount' => count($updatedUsers),
- ]
- );
-
- // Save the updated organization entity.
- $savedOrganisation = $organisationMapper->save($targetOrganisation);
-
- $this->_logger->info(
- 'StackiqService: Successfully added admin users to organization entity',
- [
- 'organizationUuid' => $organizationUuid,
- 'addedUsers' => count($updatedUsers) - count($currentUsers),
- 'totalUsers' => count($updatedUsers),
- ]
- );
- } else {
- $this->_logger->info(
- 'StackiqService: All admin users already in organization entity',
- [
- 'organizationUuid' => $organizationUuid,
- 'totalUsers' => count($updatedUsers),
- ]
- );
- }//end if
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to add admin users to organization entity: ' . $e->getMessage(),
- [
- 'organizationUuid' => $organizationUuid,
- 'exception' => $e,
- ]
- );
- }//end try
- }//end addAdminGroupUsersToOrganization()
-
- /**
- * Checks if a contactpersoon username is in the organization's users list
- *
- * @param object $contactPersonObject The contactpersoon object
- *
- * @return bool True if the user should be added to the organization
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function shouldAddContactpersoonToOrganization(object $contactPersonObject): bool {
- try {
- $objectData = $contactPersonObject->getObject();
- $username = $objectData['username'] ?? '';
- $organizationUuid = $objectData['organisation'] ?? '';
-
- if (empty($username) === true || empty($organizationUuid) === true) {
- return false;
- }
-
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- return false;
- }
-
- // Get the organization object.
- $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'organization', logContext: 'organization');
- if ($ctx === null) {
- return false;
- }
-
- $registerId = $ctx['registerId'];
- $organisationSchemaId = $ctx['schemaId'];
-
- try {
- $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisationSchemaId);
- $organizationData = $organizationObject->getObject();
-
- // Check if the username is already in the organization's users.
- $organizationUsers = $organizationData['users'] ?? [];
-
- if (is_array($organizationUsers) === true && in_array($username, $organizationUsers) === false) {
- $this->_logger->info(
- 'StackiqService: Contactpersoon should be added to organization',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- 'currentUsers' => $organizationUsers,
- ]
- );
- return true;
- }
-
- return false;
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- // Organization doesn't exist, so we can't add the user.
- $this->_logger->warning(
- 'StackiqService: Organization not found for contactpersoon',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return false;
- }//end try
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to check contactpersoon addition to org: ' . $e->getMessage(),
- [
- 'objectId' => $contactPersonObject->getId(),
- 'exception' => $e->getMessage(),
- ]
- );
- return false;
- }//end try
- }//end shouldAddContactpersoonToOrganization()
-
- /**
- * Adds a contactpersoon username to the organization's users list
- *
- * @param object $contactPersonObject The contactpersoon object
- *
- * @return bool True if the user was successfully added
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function addContactpersoonToOrganization(object $contactPersonObject): bool {
- try {
- $objectData = $contactPersonObject->getObject();
- $username = $objectData['username'] ?? '';
- $organizationUuid = $objectData['organisation'] ?? '';
-
- if (empty($username) === true || empty($organizationUuid) === true) {
- $this->_logger->warning(
- 'StackiqService: Cannot add contactpersoon to org - missing username or org',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return false;
- }
-
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- $this->_logger->error('StackiqService: OpenRegister ObjectService not available');
- return false;
- }
-
- // Get the organization object.
- $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'organization', logContext: 'organization');
- if ($ctx === null) {
- return false;
- }
-
- $registerId = $ctx['registerId'];
- $organisationSchemaId = $ctx['schemaId'];
-
- try {
- $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisationSchemaId);
- $organizationData = $organizationObject->getObject();
-
- // Add the username to the organization's users list.
- $organizationUsers = $organizationData['users'] ?? [];
- if (is_array($organizationUsers) === false) {
- $organizationUsers = [];
- }
-
- if (in_array($username, $organizationUsers) === false) {
- $organizationUsers[] = $username;
- $organizationData['users'] = $organizationUsers;
-
- // Update the organization object.
- $updatedOrganization = $objectService->saveObject(
- $organizationData,
- [],
- $registerId,
- $organisationSchemaId,
- $organizationUuid
- );
-
- $this->_logger->info(
- 'StackiqService: Successfully added contactpersoon to organization',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- 'updatedUsers' => $organizationUsers,
- ]
- );
- }//end if
-
- $this->_logger->debug(
- 'StackiqService: Contactpersoon already in organization',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return true;
- // Already there, consider it successful.
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- $this->_logger->error(
- 'StackiqService: Organization not found for contactpersoon',
- [
- 'username' => $username,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return false;
- }//end try
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to add contact person to organization: ' . $e->getMessage(),
- [
- 'objectId' => $contactPersonObject->getId(),
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- return false;
- }//end try
- }//end addContactpersoonToOrganization()
-
- /**
- * Handles ownership assignment for anonymous user registrations
- *
- * @param object $organizationObject The organization object
- *
- * @return void
- */
- private function handleOwnershipAssignment(object $organizationObject): void {
- try {
- $this->_logger->info(
- 'StackiqService: Handling ownership assignment for organization',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
-
- $objectData = $organizationObject->getObject();
- $organizationUuid = $objectData['id'] ?? $organizationObject->getId();
- $contactpersonen = $objectData['contactpersonen'] ?? [];
-
- if (empty($contactpersonen) === true) {
- $this->_logger->info(
- 'StackiqService: No contact persons found for ownership assignment',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }
-
- // Get the first contact person as the primary owner.
- $primaryContactUuid = $contactpersonen[0];
-
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- $this->_logger->error(
- 'StackiqService: OpenRegister ObjectService not available for ownership assignment'
- );
- return;
- }
-
- // Get the primary contact person object.
- $settingsService = $this->_container->get(SettingsService::class);
- $registerId = $settingsService->getVoorzieningenRegisterId();
- $contactPersonSchemaId = $settingsService->getSchemaIdForObjectType('contactPerson');
- $organisationSchemaId = $settingsService->getSchemaIdForObjectType('organization');
-
- if ($registerId === null || $contactPersonSchemaId === null || $organisationSchemaId === false) {
- $this->_logger->error(
- 'StackiqService: Register or schema not configured for contactpersoon/organisatie'
- );
- return;
- }
-
- // Retry mechanism for user creation timing.
- $maxRetries = 3;
- $retryDelay = 1;
- // Seconds.
- for ($retry = 0; $retry < $maxRetries; $retry++) {
- try {
- $primaryContactObject = $objectService->find(
- $primaryContactUuid,
- [],
- false,
- $registerId,
- $contactPersonSchemaId
- );
- $primaryContactData = $primaryContactObject->getObject();
- $primaryUsername = $primaryContactData['username'] ?? '';
-
- if (empty($primaryUsername) === true) {
- if ($retry < $maxRetries - 1) {
- $this->_logger->info(
- 'StackiqService: Primary contact no username, retry in ' . $retryDelay . 's',
- [
- 'contactUuid' => $primaryContactUuid,
- 'organizationUuid' => $organizationUuid,
- 'retry' => $retry + 1,
- 'maxRetries' => $maxRetries,
- ]
- );
- sleep($retryDelay);
- }
-
- $this->_logger->warning(
- 'StackiqService: Primary contact person still has no username after retries',
- [
- 'contactUuid' => $primaryContactUuid,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }//end if
-
- // Get the organization entity UUID - use the same UUID as the organization object.
- $organisationEntityUuid = $organizationUuid;
- // Organization entity should have same UUID as object.
- // Add users to the organization entity.
- $organisationMapper = $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper');
- try {
- $organisationEntity = $organisationMapper->findByUuid($organisationEntityUuid);
-
- // Add all contact person users to the organization entity.
- foreach ($contactpersonen as $contactUuid) {
- try {
- $contactObject = $objectService->find(
- $contactUuid,
- [],
- false,
- $registerId,
- $contactPersonSchemaId
- );
- $contactData = $contactObject->getObject();
- $contactUsername = $contactData['username'] ?? '';
-
- if (empty($contactUsername) === false) {
- $organisationEntity->addUser($contactUsername);
- }
- } catch (\Exception $e) {
- $this->_logger->warning(
- 'StackiqService: Failed to add contact person to organization entity',
- [
- 'contactUuid' => $contactUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end foreach
-
- // Save the updated organization entity.
- $organisationMapper->save($organisationEntity);
-
- $this->_logger->info(
- 'StackiqService: Successfully added users to organization entity',
- [
- 'organizationUuid' => $organisationEntityUuid,
- 'userCount' => count($organisationEntity->getUserIds()),
- ]
- );
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- $this->_logger->error(
- 'StackiqService: Organization entity not found for adding users',
- [
- 'organizationUuid' => $organisationEntityUuid,
- ]
- );
- }//end try
-
- // Update organization object ownership and organization reference.
- $organizationData['owner'] = $primaryUsername;
- $organizationData['organisation'] = $organisationEntityUuid;
-
- $updatedOrganization = $objectService->saveObject(
- $organizationData,
- [],
- $registerId,
- $organisationSchemaId,
- $organizationUuid
- );
-
- // Update primary contact person object ownership and organization reference.
- $primaryContactData['owner'] = $primaryUsername;
- $primaryContactData['organization'] = $organisationEntityUuid;
-
- $updatedPrimaryContact = $objectService->saveObject(
- $primaryContactData,
- [],
- $registerId,
- $contactPersonSchemaId,
- $primaryContactUuid
- );
-
- // Update other contact persons with organization reference.
- for ($i = 1; $i < count($contactpersonen); $i++) {
- $contactUuid = $contactpersonen[$i];
- try {
- $contactObject = $objectService->find(
- $contactUuid,
- [],
- false,
- $registerId,
- $contactPersonSchemaId
- );
- $contactData = $contactObject->getObject();
- $contactUsername = $contactData['username'] ?? '';
-
- if (empty($contactUsername) === false) {
- $contactData['owner'] = $contactUsername;
- $contactData['organization'] = $organisationEntityUuid;
-
- $objectService->saveObject(
- $contactData,
- [],
- $registerId,
- $contactPersonSchemaId,
- $contactUuid
- );
- }
- } catch (\Exception $e) {
- $this->_logger->warning(
- 'StackiqService: Failed to update contact person ownership',
- [
- 'contactUuid' => $contactUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end for
-
- $this->_logger->info(
- 'StackiqService: Successfully assigned ownership for organization',
- [
- 'organizationUuid' => $organizationUuid,
- 'primaryOwner' => $primaryUsername,
- 'organisationEntityUuid' => $organisationEntityUuid,
- 'contactPersonCount' => count($contactpersonen),
- 'retries' => $retry,
- ]
- );
-
- return;
- // Success, exit retry loop.
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- if ($retry < $maxRetries - 1) {
- $this->_logger->info(
- 'StackiqService: Primary contact not found, retrying in ' . $retryDelay . ' seconds',
- [
- 'contactUuid' => $primaryContactUuid,
- 'organizationUuid' => $organizationUuid,
- 'retry' => $retry + 1,
- 'maxRetries' => $maxRetries,
- ]
- );
- sleep($retryDelay);
- }
-
- $this->_logger->error(
- 'StackiqService: Primary contact person not found after retries',
- [
- 'contactUuid' => $primaryContactUuid,
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }//end try
- }//end for
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error handling ownership assignment',
- [
- 'objectId' => $organizationObject->getId(),
- 'error' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- ]
- );
- }//end try
- }//end handleOwnershipAssignment()
-
- /**
- * Synchronizes contact person usernames with the organization entity's users array
- * This method finds all contact persons associated with a given organization UUID
- * and ensures their emails are present in the organization entity's users array
- *
- * @param string $organizationUuid The UUID of the organization
- *
- * @return void
- * @spec openspec/specs/softwarecatalogue-orchestration/spec.md
- */
- public function syncContactPersonUsernamesWithOrganization(string $organizationUuid): void {
- $this->_logger->info(
- 'StackiqService: Starting contact person username synchronization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
-
- // Get the ObjectService to find contact persons.
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- $this->_logger->error('StackiqService: ObjectService not available for username synchronization');
- return;
- }
-
- // Get the contact person schema ID from configuration.
- $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService');
- $voorzieningenConfig = $settingsService->getVoorzieningenConfig();
- $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null;
- $registerId = $voorzieningenConfig['register'] ?? null;
-
- if ($contactSchemaId === null || $registerId === false) {
- $this->_logger->warning(
- 'StackiqService: Missing Voorzieningen configuration for contact person sync',
- [
- 'organizationUuid' => $organizationUuid,
- 'contactSchemaId' => $contactSchemaId,
- 'registerId' => $registerId,
- ]
- );
- return;
- }
-
- try {
- // Find all contact persons that have this organization as their organisatie.
- $contactPersons = $objectService->findAll(
- [
- 'filters' => [
- 'register' => (string)$registerId,
- 'schema' => $contactSchemaId,
- 'organization' => $organizationUuid,
- ],
- ]
- );
-
- $this->_logger->info(
- 'StackiqService: Found contact persons for synchronization',
- [
- 'organizationUuid' => $organizationUuid,
- 'contactPersonCount' => count($contactPersons),
- ]
- );
-
- // Collect all usernames from contact persons.
- $contactPersonUsernames = [];
- foreach ($contactPersons as $contactPerson) {
- $contactData = $contactPerson->getObject();
- $email = $contactData['email'] ?? null;
- if (empty($email) === false) {
- $contactPersonUsernames[] = $email;
- $this->_logger->info(
- 'StackiqService: Found contact person username',
- [
- 'username' => $email,
- 'contactPersonId' => $contactPerson->getId(),
- ]
- );
- }
- }
-
- // Get the organization entity.
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- $this->_logger->error(
- 'StackiqService: OpenRegister OrganisationMapper not available for synchronization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }
-
- $organisation = $organisationMapper->findByUuid($organizationUuid);
-
- if ($organisation === null) {
- $this->_logger->error(
- 'StackiqService: Organization entity not found for synchronization',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }
-
- // Get current users and add contact person usernames.
- $currentUsers = $organisation->getUsers() ?? [];
- $allUsers = array_merge($currentUsers, $contactPersonUsernames);
- $allUsers = array_unique($allUsers);
-
- $this->_logger->info(
- 'StackiqService: Updating organization entity users',
- [
- 'organizationUuid' => $organizationUuid,
- 'currentUsers' => $currentUsers,
- 'contactPersonUsernames' => $contactPersonUsernames,
- 'finalUsers' => $allUsers,
- ]
- );
-
- // Update the organization entity.
- $organisation->setUsers($allUsers);
- $organisationMapper->save($organisation);
-
- $this->_logger->info(
- 'StackiqService: Successfully synchronized contact person usernames',
- [
- 'organizationUuid' => $organizationUuid,
- 'totalUsers' => count($allUsers),
- ]
- );
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- // Organization entity doesn't exist yet - this can happen due to race conditions.
- // Log and return gracefully, the organization sync will handle this later.
- $this->_logger->warning(
- 'StackiqService: Organization entity not found during username sync (race condition)',
- [
- 'organizationUuid' => $organizationUuid,
- 'message' => 'Expected during anonymous registration - org entity created after contacts',
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Error synchronizing contact person usernames',
- [
- 'organizationUuid' => $organizationUuid,
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
- }//end syncContactPersonUsernamesWithOrganization()
-
- /**
- * Ensures a contact person's username is in their organization's users array.
- * NOTE: Dead method — retained only as implementation reference until the sync
- * pipeline invocation point is wired; not called from any live code path.
- *
- * @param object $contactPersonObject The contact person object
- *
- * @SuppressWarnings(PHPMD.UnusedPrivateMethod)
- *
- * @return void
- */
- private function ensureContactPersonInOrganization(object $contactPersonObject): void {
- $contactData = $contactPersonObject->getObject();
- $email = $contactData['email'] ?? null;
- $organization = $contactData['organization'] ?? null;
-
- if ($email === null || $organization === false) {
- $this->_logger->info(
- 'StackiqService: Contact person missing email or organisation',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'hasEmail' => empty($email) === false,
- 'hasOrganisatie' => empty($organization) === false,
- ]
- );
- return;
- }
-
- // Skip if the contact person is owned by the default organization.
- $owner = $contactPersonObject->getOwner();
- if ($owner === 'system') {
- $this->_logger->info(
- 'StackiqService: Skipping contact person owned by system',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'username' => $email,
- ]
- );
- return;
- }
-
- $this->_logger->info(
- 'StackiqService: Ensuring contact person in organization',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'username' => $email,
- 'organization' => $organization,
- ]
- );
-
- try {
- // Get the organization entity.
- $organisationMapper = $this->getOrganisationMapper();
- if ($organisationMapper === null) {
- $this->_logger->error(
- 'StackiqService: OpenRegister OrganisationMapper not available for contact person',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'organization' => $organization,
- ]
- );
- return;
- }
-
- $organisation = $organisationMapper->findByUuid($organization);
-
- if ($organisation === null) {
- $this->_logger->error(
- 'StackiqService: Organization entity not found for contact person',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'organization' => $organization,
- ]
- );
- return;
- }
-
- // Check if the username is already in the organization's users array.
- $currentUsers = $organisation->getUsers() ?? [];
- if (in_array($email, $currentUsers) === true) {
- $this->_logger->info(
- 'StackiqService: Contact person already in organization',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'username' => $email,
- 'organization' => $organization,
- ]
- );
- return;
- }
-
- // Add the username to the organization's users array.
- $currentUsers[] = $email;
- $organisation->setUsers($currentUsers);
- $organisationMapper->save($organisation);
-
- $this->_logger->info(
- 'StackiqService: Successfully added contact person to organization',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'username' => $email,
- 'organization' => $organization,
- 'totalUsers' => count($currentUsers),
- ]
- );
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- // Organization entity doesn't exist yet - this can happen due to race conditions.
- // Log and return gracefully, the organization sync will handle this later.
- $this->_logger->warning(
- 'StackiqService: Org entity not found (race condition), handled by org sync',
- [
- 'contactPersonId' => $contactPersonObject->getId(),
- 'username' => $email,
- 'organization' => $organization,
- 'message' => 'Expected during anonymous registration - org entity created after contacts',
- ]
- );
- return;
- }//end try
- }//end ensureContactPersonInOrganization()
-
- /**
- * Updates organization references on objects to point to the newly created organization entity
- *
- * @param object $organizationObject The organization object
- *
- * @return void
- */
- private function updateOrganizationReferences(object $organizationObject): void {
- try {
- $this->_logger->info(
- 'StackiqService: Updating organization references',
- [
- 'objectId' => $organizationObject->getId(),
- ]
- );
-
- $objectData = $organizationObject->getObject();
- $organizationUuid = $objectData['id'] ?? $organizationObject->getId();
-
- // Get the ObjectService to update objects.
- $objectService = $this->getObjectService();
- if ($objectService === null) {
- $this->_logger->error('StackiqService: ObjectService not available for updating references');
- return;
- }
-
- // Get the organization entity UUID (should be the same as the organization object UUID).
- $organisationMapper = $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper');
- try {
- // Use the original UUID format for OpenRegister lookup.
- $organisationEntity = $organisationMapper->findByUuid($organizationUuid);
- $organisationEntityUuid = $organisationEntity->getUuid();
-
- $this->_logger->info(
- 'StackiqService: Found organization entity for reference update',
- [
- 'organizationObjectUuid' => $organizationUuid,
- 'organizationEntityUuid' => $organisationEntityUuid,
- ]
- );
- } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
- $this->_logger->error(
- 'StackiqService: Organization entity not found for reference update',
- [
- 'organizationUuid' => $organizationUuid,
- ]
- );
- return;
- }//end try
-
- // Update the organization object's @self.organisation field.
- $this->_logger->info(
- 'StackiqService: Updating organization object reference',
- [
- 'objectId' => $organizationObject->getId(),
- 'newOrganisationUuid' => $organisationEntityUuid,
- ]
- );
-
- // Get the current object data and update the organisation field.
- $currentObjectData = $organizationObject->getObject();
- $currentObjectData['@self']['organisation'] = $organisationEntityUuid;
-
- // Update the organization object using the ObjectService.
- // Don't update version, not a patch, no extend.
- $objectService->saveObject(
- object: $currentObjectData,
- register: $organizationObject->getRegisterId(),
- schema: $organizationObject->getSchemaId(),
- uuid: $organizationObject->getUuid()
- );
-
- // Update contact person objects' @self.organization field.
- $contactpersonen = $objectData['contactpersonen'] ?? [];
- foreach ($contactpersonen as $contactUuid) {
- $this->_logger->info(
- 'StackiqService: Updating contact person object reference',
- [
- 'contactUuid' => $contactUuid,
- 'newOrganisationUuid' => $organisationEntityUuid,
- ]
- );
-
- // Get the contact person schema ID from configuration.
- $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService');
- $voorzieningenConfig = $settingsService->getVoorzieningenConfig();
- $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null;
-
- if ($contactSchemaId === null) {
- $this->_logger->warning(
- 'StackiqService: Missing contactpersoon schema configuration for object update',
- [
- 'contactUuid' => $contactUuid,
- ]
- );
- continue;
- }
-
- // Find the contact person object.
- try {
- $regId = $organizationObject->getRegisterId();
- $contactObject = $objectService->find(
- $contactUuid,
- [],
- false,
- $regId,
- $contactSchemaId
- );
- if (empty($contactObject) === false) {
- // Get the current object data and update the organisatie field.
- $contactObjectData = $contactObject->getObject();
- $contactObjectData['@self']['organization'] = $organisationEntityUuid;
-
- // Update the contact person object using the ObjectService.
- // Don't update version, not a patch, no extend.
- $objectService->saveObject(
- object: $contactObjectData,
- register: $organizationObject->getRegisterId(),
- schema: $contactSchemaId,
- uuid: $contactObject->getUuid()
- );
- }
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to update contact person object',
- [
- 'contactUuid' => $contactUuid,
- 'error' => $e->getMessage(),
- ]
- );
- }//end try
- }//end foreach
-
- $this->_logger->info(
- 'StackiqService: Successfully updated organization references',
- [
- 'organizationUuid' => $organizationUuid,
- 'organizationEntityUuid' => $organisationEntityUuid,
- 'contactPersonCount' => count($contactpersonen),
- ]
- );
- } catch (\Exception $e) {
- $this->_logger->error(
- 'StackiqService: Failed to update organization references: ' . $e->getMessage(),
- [
- 'objectId' => $organizationObject->getId(),
- 'exception' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString(),
- ]
- );
- }//end try
- }//end updateOrganizationReferences()
-
- /**
- * Resolves the voorzieningen register id + a schema id for a given
- * object-type slug.
- *
- * Extracted from the repeated four-line "fetch SettingsService +
- * getVoorzieningenRegisterId + getSchemaIdForObjectType + null/false
- * guard" block that appeared in `activateUsersForOrganization()`,
- * `deactivateUsersForOrganization()`,
- * `shouldAddContactpersoonToOrganization()`,
- * `addContactpersoonToOrganization()`, and
- * `updateOrganizationReferences()`. Centralises the "missing config"
- * log line so the per-method bodies no longer carry that boilerplate.
- *
- * W31 method-decomposition 2.6 — companion helper for the
- * Stackiq subservice wiring.
- *
- * @param string $schemaSlug Object-type slug as understood by
- * `SettingsService::getSchemaIdForObjectType()`
- * (e.g. `contactpersoon`, `organisatie`).
- * @param string $logContext Short human label for the missing-config
- * log line (e.g. `contactpersonen`).
- *
- * @return array{registerId:int, schemaId:int}|null Null when either
- * the voorzieningen register or the schema
- * is unconfigured; the matched (registerId,
- * schemaId) pair otherwise.
- *
- * @spec openspec/changes/method-decomposition/tasks.md#task-2-6
- */
- private function resolveVoorzieningenContext(string $schemaSlug, string $logContext): ?array {
- $settingsService = $this->_container->get(SettingsService::class);
- $registerId = $settingsService->getVoorzieningenRegisterId();
- $schemaId = $settingsService->getSchemaIdForObjectType($schemaSlug);
-
- if ($registerId === null || $schemaId === null || $schemaId === false) {
- $this->_logger->error(
- 'StackiqService: Register or schema not configured for ' . $logContext
- );
- return null;
- }
-
- return [
- 'registerId' => $registerId,
- 'schemaId' => $schemaId,
- ];
-
- }//end resolveVoorzieningenContext()
-}//end class
diff --git a/lib/Settings/register.d/catalog-ratings.json b/lib/Settings/register.d/catalog-ratings.json
index 503be2213..af8d4f975 100644
--- a/lib/Settings/register.d/catalog-ratings.json
+++ b/lib/Settings/register.d/catalog-ratings.json
@@ -46,7 +46,8 @@
"group": "public",
"match": {
"status": "approved"
- }
+ },
+ "crossOrganisation": true
},
"aanbod-beheerder",
"ambtenaar",
diff --git a/lib/Settings/register.d/data-quality.json b/lib/Settings/register.d/data-quality.json
new file mode 100644
index 000000000..2394cbd29
--- /dev/null
+++ b/lib/Settings/register.d/data-quality.json
@@ -0,0 +1,75 @@
+{
+ "_comment": "landscape-completeness-score (board SkDatakwaliteit). The rules OpenRegister scores a usage by on every save, and the body fields it writes the score to. The Data quality page (GET /api/data-quality) reads these same rules to count, per rule, how many of an organisation's usages meet them. The module schema already declared its rules; it gets the body fields so its score can be listed too.",
+ "components": {
+ "schemas": {
+ "usage": {
+ "version": "1.5.9",
+ "configuration": {
+ "x-openregister-quality": {
+ "field": "qualityScore",
+ "statusField": "qualityStatus",
+ "rules": [
+ { "type": "required", "field": "module", "weight": 2 },
+ { "type": "required", "field": "moduleVersion", "weight": 1 },
+ { "type": "required", "field": "status", "weight": 1 },
+ { "type": "required", "field": "businessOwner", "weight": 2 },
+ { "type": "required", "field": "technicalOwner", "weight": 2 },
+ { "type": "required", "field": "usedForReferenceComponents", "weight": 2 },
+ { "type": "freshness", "field": "lastConfirmedAt", "halfLifeDays": 365, "weight": 3 }
+ ],
+ "thresholds": { "good": 0.8, "fair": 0.5 }
+ }
+ },
+ "properties": {
+ "lastConfirmedAt": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Last confirmed",
+ "description": "When someone last confirmed this entry is current. The freshness rule halves its weight every year after.",
+ "visible": true,
+ "order": 90
+ },
+ "qualityScore": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "title": "Data quality score",
+ "description": "Written by OpenRegister on save from the declared rules.",
+ "hideOnForm": true,
+ "facetable": false
+ },
+ "qualityStatus": {
+ "type": "string",
+ "enum": ["good", "fair", "poor"],
+ "title": "Data quality",
+ "description": "Written by OpenRegister on save: good from 0.8, fair from 0.5.",
+ "hideOnForm": true,
+ "facetable": true
+ }
+ }
+ },
+ "module": {
+ "version": "0.3.11",
+ "properties": {
+ "qualityScore": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "title": "Data quality score",
+ "description": "Written by OpenRegister on save from the declared rules.",
+ "hideOnForm": true,
+ "facetable": false
+ },
+ "qualityStatus": {
+ "type": "string",
+ "enum": ["good", "fair", "poor"],
+ "title": "Data quality",
+ "description": "Written by OpenRegister on save: good from 0.8, fair from 0.5.",
+ "hideOnForm": true,
+ "facetable": true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lib/Settings/register.d/organisation-registration.json b/lib/Settings/register.d/organisation-registration.json
new file mode 100644
index 000000000..8d180e866
--- /dev/null
+++ b/lib/Settings/register.d/organisation-registration.json
@@ -0,0 +1,64 @@
+{
+ "$comment": "organisation-registration-through-the-portal (board SkPubliek): what the registration form on the municipality website asks, so the anonymous create keeps it. The contact of the person who registered is for the reviewing administrator: only signed-in users read it, like registrationStatus. AnonymousRegistrationListener stores every anonymous create as pending.",
+ "components": {
+ "schemas": {
+ "organization": {
+ "version": "0.5.5",
+ "properties": {
+ "website": {
+ "type": "string",
+ "format": "url",
+ "title": "Website",
+ "description": "The organisation's website.",
+ "maxLength": 500,
+ "visible": true,
+ "order": 19
+ },
+ "registrationNumber": {
+ "type": "string",
+ "title": "KvK number or OIN",
+ "description": "The number the organisation registered with: a KvK number (8 digits) or an OIN (20 digits), for the reviewing administrator.",
+ "pattern": "^(\\d{8}|\\d{20})$",
+ "visible": true,
+ "hideOnCollection": true,
+ "order": 20,
+ "authorization": {
+ "read": [
+ "authenticated"
+ ]
+ }
+ },
+ "registrationContactName": {
+ "type": "string",
+ "title": "Registered by",
+ "description": "The person who registered the organisation, for the reviewing administrator.",
+ "maxLength": 200,
+ "visible": true,
+ "hideOnCollection": true,
+ "order": 21,
+ "authorization": {
+ "read": [
+ "authenticated"
+ ]
+ }
+ },
+ "registrationContactEmail": {
+ "type": "string",
+ "format": "email",
+ "title": "E-mail of the registering person",
+ "description": "Where the reviewing administrator sends the activation. Not shown to anonymous readers.",
+ "maxLength": 320,
+ "visible": true,
+ "hideOnCollection": true,
+ "order": 22,
+ "authorization": {
+ "read": [
+ "authenticated"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lib/Settings/register.d/publication-field-rules.json b/lib/Settings/register.d/publication-field-rules.json
index a939858af..79d600442 100644
--- a/lib/Settings/register.d/publication-field-rules.json
+++ b/lib/Settings/register.d/publication-field-rules.json
@@ -115,13 +115,15 @@
"modulePublicationDate": {
"$lte": "$now"
}
- }
+ },
+ "crossOrganisation": true
},
{
"group": "public",
"match": {
"moduleRegisteredBy": "Supplier"
- }
+ },
+ "crossOrganisation": true
}
]
}
@@ -139,7 +141,7 @@
}
},
"catalogService": {
- "version": "0.2.2",
+ "version": "0.2.3",
"properties": {
"contactPerson": {
"authorization": {
@@ -451,7 +453,8 @@
"group": "gebruik-beheerder",
"match": {
"consumer": "$organisation"
- }
+ },
+ "crossOrganisation": true
},
{
"group": "aanbod-beheerder",
@@ -463,7 +466,8 @@
"group": "aanbod-beheerder",
"match": {
"provider": "$organisation"
- }
+ },
+ "crossOrganisation": true
},
{
"group": "public",
@@ -471,13 +475,14 @@
"publicationDate": {
"$lte": "$now"
}
- }
+ },
+ "crossOrganisation": true
}
]
}
},
"organization": {
- "version": "0.5.3",
+ "version": "0.5.4",
"properties": {
"contactsUid": {
"authorization": {
diff --git a/lib/Settings/register.d/topdesk-cmdb-import.json b/lib/Settings/register.d/topdesk-cmdb-import.json
index 977c5db24..4e98aa6d8 100644
--- a/lib/Settings/register.d/topdesk-cmdb-import.json
+++ b/lib/Settings/register.d/topdesk-cmdb-import.json
@@ -2,7 +2,7 @@
"components": {
"schemas": {
"module": {
- "version": "0.3.9",
+ "version": "0.3.10",
"configuration": {
"x-openregister-archive": { "enabled": true }
},
@@ -75,7 +75,7 @@
}
},
"usage": {
- "version": "1.5.7",
+ "version": "1.5.8",
"configuration": {
"x-openregister-archive": { "enabled": true },
"x-openregister-lifecycle": {
diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json
index 7a2976760..7f1fad256 100644
--- a/lib/Settings/softwarecatalogus_register.json
+++ b/lib/Settings/softwarecatalogus_register.json
@@ -3,8 +3,8 @@
"info": {
"title": "Software Catalog Register",
"description": "Register containing AMEF and Voorzieningen schemas for the VNG Software Catalog application. This configuration includes schemas for applications, services, organizations, and compliance tracking.",
- "version": "2.5.7",
- "changelog": "2.5.7: a contract no longer requires a catalogue service, because a licence bought for an application in use has none; the service desk exchange imports such licences (sharing-itsm-exchange). The fragment sharing-itsm-exchange.json adds the service desk reference to usage (1.5.4), connection (0.3.4) and catalogContract (0.1.4), and the licence fields vendorReference, currency and supplier to catalogContract. 2.5.6: contactsUid is no longer required on organization (0.5.2) and contactPerson (0.0.28). It is a link to the Nextcloud addressbook that the contacts sync fills in after the record exists (MigrateContactsToNc, OrganizationContactSyncJob), so a required column made OpenRegister create it NOT NULL and every seed organisation failed to save on a fresh install (the organization table stayed empty). OpenRegister drops the NOT NULL on the existing column when the schema syncs. 2.5.5: the maintenanceWindow schema (0.1.0) joins the stackiq register, for maintenance a supplier plans on its products, with the owners of every usage notified (lifecycle-maintenance-and-supplier-roadmap). 2.5.4: usage (1.5.2) gains businessOwner and technicalOwner, contact persons of the consumer organisation; a usage is named after its application and organisation; status becomes facetable for the Applications in use filters (landscape-usage-registration). 2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files."
+ "version": "2.5.8",
+ "changelog": "2.5.8: schema properties no longer carry null validation keywords (minimum, maximum, multipleOf, minItems, maxItems, minLength, maxLength). OpenRegister's JSON Schema validator rejects a null there ('minimum must contain a valid number'), so no object could be created on a schema with hardValidation, such as element, and the E2E seed failed. Bumped: catalogService (0.2.2), contactPerson (0.0.29), organization (0.5.3), usage (1.5.3), element (0.0.12), view (0.0.8), model (0.0.44), property-definition (0.0.9), relation (0.0.9), module (0.3.4). The loader keeps the highest version per schema, so the fragments that carry the effective version of catalogService and organization (publication-field-rules.json: 0.2.3, 0.5.4) and of usage and module (topdesk-cmdb-import.json: 1.5.7, 0.3.9) are bumped too; an equal version is skipped on upgrade. 2.5.7: a contract no longer requires a catalogue service, because a licence bought for an application in use has none; the service desk exchange imports such licences (sharing-itsm-exchange). The fragment sharing-itsm-exchange.json adds the service desk reference to usage (1.5.4), connection (0.3.4) and catalogContract (0.1.4), and the licence fields vendorReference, currency and supplier to catalogContract. 2.5.6: contactsUid is no longer required on organization (0.5.2) and contactPerson (0.0.28). It is a link to the Nextcloud addressbook that the contacts sync fills in after the record exists (MigrateContactsToNc, OrganizationContactSyncJob), so a required column made OpenRegister create it NOT NULL and every seed organisation failed to save on a fresh install (the organization table stayed empty). OpenRegister drops the NOT NULL on the existing column when the schema syncs. 2.5.5: the maintenanceWindow schema (0.1.0) joins the stackiq register, for maintenance a supplier plans on its products, with the owners of every usage notified (lifecycle-maintenance-and-supplier-roadmap). 2.5.4: usage (1.5.2) gains businessOwner and technicalOwner, contact persons of the consumer organisation; a usage is named after its application and organisation; status becomes facetable for the Applications in use filters (landscape-usage-registration). 2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files."
},
"x-openregister": {
"type": "application",
@@ -1091,7 +1091,7 @@
"aanbod-beheerder"
],
"read": [
- "public",
+ {"group": "public", "crossOrganisation": true},
"aanbod-beheerder",
"ambtenaar",
"functioneel-beheerder",
@@ -1270,7 +1270,7 @@
"aanbod-beheerder"
],
"read": [
- "public",
+ {"group": "public", "crossOrganisation": true},
"aanbod-beheerder",
"ambtenaar",
"functioneel-beheerder",
@@ -1329,7 +1329,7 @@
"x-schema-org": "schema:Service",
"title": "Service",
"description": "Een specifiek aanbod van een dienst op een of meerdere applicaties door een leverancier",
- "version": "0.2.1",
+ "version": "0.2.2",
"omschrijving": "",
"icon": "Handshake",
"required": [
@@ -1344,13 +1344,7 @@
"required": true,
"visible": true,
"order": 1,
- "minLength": null,
"maxLength": 200,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"table": {
"default": true
@@ -1565,7 +1559,8 @@
"publicationDate": {
"$lte": "$now"
}
- }
+ },
+ "crossOrganisation": true
},
"aanbod-beheerder",
"ambtenaar",
@@ -1741,7 +1736,7 @@
"aanbod-beheerder"
],
"read": [
- "public",
+ {"group": "public", "crossOrganisation": true},
"aanbod-beheerder",
"ambtenaar",
"functioneel-beheerder",
@@ -1791,7 +1786,7 @@
"x-schema-org": "schema:Person",
"title": "Contact person",
"description": "Contactgegevens van een persoon",
- "version": "0.0.28",
+ "version": "0.0.29",
"omschrijving": "",
"icon": "AccountMultiple",
"required": [],
@@ -1810,13 +1805,7 @@
"description": "Functie van de medewerker",
"type": "string",
"visible": true,
- "minLength": null,
"maxLength": 100,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -1868,13 +1857,6 @@
"visible": true,
"facetable": false,
"order": 7,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"register": "",
@@ -2022,7 +2004,7 @@
"x-schema-org": "schema:Organization",
"title": "Organization",
"description": "An organisation that offers provisions. Absorbs the former ArchiMate `organization` schema: its identity and statutory identifiers (name, summary, description, oin, tooi, rsin, pki, image) and its ArchiMate round-trip `xml` are declared here, so there is one organisation schema rather than two that shared no property.",
- "version": "0.5.2",
+ "version": "0.5.3",
"omschrijving": "",
"icon": "OfficeBuildingOutline",
"required": [
@@ -2156,13 +2138,6 @@
"type": "array",
"visible": true,
"order": 4,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "#/components/schemas/contactPerson",
"x-relation-filter": { "organization": "@objectId" },
@@ -2199,13 +2174,6 @@
"type": "array",
"visible": true,
"order": 5,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "#/components/schemas/organization",
"items": {
@@ -2235,13 +2203,6 @@
"type": "array",
"visible": true,
"order": 6,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "#/components/schemas/organization",
"items": {
@@ -2282,14 +2243,7 @@
"order": null
},
"order": 3,
- "minLength": null,
- "maxLength": null,
"immutable": true,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2320,13 +2274,6 @@
"hideOnCollection": true,
"facetable": false,
"order": 17,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2397,13 +2344,6 @@
"visible": false,
"facetable": true,
"order": 14,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2488,7 +2428,8 @@
"publicationDate": {
"$lte": "$now"
}
- }
+ },
+ "crossOrganisation": true
},
{
"group": "aanbod-beheerder",
@@ -2501,21 +2442,24 @@
"match": {
"registeredBy": "Supplier",
"status": "Active"
- }
+ },
+ "crossOrganisation": true
},
{
"group": "public",
"match": {
"type": "Municipality",
"status": "Active"
- }
+ },
+ "crossOrganisation": true
},
{
"group": "public",
"match": {
"type": "Collaboration",
"status": "Active"
- }
+ },
+ "crossOrganisation": true
}
],
"update": [
@@ -2653,7 +2597,7 @@
"slug": "usage",
"title": "Usage",
"description": "Het gebruik van applicaties, diensten en koppelingen door afnemers",
- "version": "1.5.2",
+ "version": "1.5.3",
"omschrijving": "",
"icon": "Gauge",
"x-openregister-notifications": {
@@ -2745,13 +2689,6 @@
"format": "date",
"visible": true,
"order": 16,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2775,13 +2712,6 @@
"format": "date",
"visible": true,
"order": 14,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2805,13 +2735,6 @@
"format": "date",
"visible": true,
"order": 15,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2835,13 +2758,6 @@
"format": "date",
"visible": true,
"order": 15,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2866,13 +2782,6 @@
"required": true,
"visible": true,
"order": 17,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -2903,13 +2812,6 @@
"visible": true,
"hideOnCollection": true,
"order": 10,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": {
@@ -3157,7 +3059,8 @@
"group": "gebruik-beheerder",
"match": {
"consumer": "$organisation"
- }
+ },
+ "crossOrganisation": true
},
{
"group": "aanbod-beheerder",
@@ -3169,7 +3072,8 @@
"group": "aanbod-beheerder",
"match": {
"provider": "$organisation"
- }
+ },
+ "crossOrganisation": true
}
],
"update": [
@@ -3482,8 +3386,8 @@
"aanbod-beheerder"
],
"read": [
- "ambtenaar",
- "software-catalog-admins",
+ {"group": "ambtenaar", "crossOrganisation": true},
+ {"group": "software-catalog-admins", "crossOrganisation": true},
{"group": "functioneel-beheerder", "match": {"_organisation": "$organisation"}},
{"group": "gebruik-beheerder", "match": {"_organisation": "$organisation"}},
{"group": "vng-raadpleger", "match": {"_organisation": "$organisation"}},
@@ -3875,7 +3779,8 @@
"publicationDate": {
"$lte": "$now"
}
- }
+ },
+ "crossOrganisation": true
},
{
"group": "aanbod-beheerder",
@@ -3887,7 +3792,8 @@
"group": "aanbod-beheerder",
"match": {
"provider": "$organisation"
- }
+ },
+ "crossOrganisation": true
}
],
"update": [
@@ -4114,7 +4020,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -4129,7 +4035,7 @@
"slug": "element",
"title": "Element",
"description": "AMEF Element - Architectuur elementen uit het ArchiMate model",
- "version": "0.0.11",
+ "version": "0.0.12",
"omschrijving": "",
"icon": "Cube",
"required": [
@@ -4141,14 +4047,7 @@
"identifier": {
"description": "De identifier van dit Element",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "id-009fa62f25844aa3a87d252bf2b6bb0c",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -4171,14 +4070,7 @@
"type": {
"description": "Het type van dit Element",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Capability",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -4202,14 +4094,7 @@
"name": {
"description": "De naam van dit Element",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Publiceren en gebruiken van informatie over datadiensten",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -4236,11 +4121,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -4264,14 +4144,7 @@
"documentation": {
"description": "De documentation van dit Element",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Dienstenafnemers moeten in online catalogi kunnen opvragen welke diensten, met welke kenmerken, door dienstenaanbieder worden aangeboden. \\nOnder andere ontwikkelaars hebben baat bij informatie over beschikbare diensten en de vereisten voor het gebruik van de dienst (bijv. specificatie van een dienst conform de OAS-standaard).",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -4298,11 +4171,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -4326,13 +4194,6 @@
"properties": {
"description": "De properties van dit Element",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -5283,7 +5144,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -5298,7 +5159,7 @@
"slug": "view",
"title": "View",
"description": "AMEF View - Architectuur views en diagrammen uit het ArchiMate model",
- "version": "0.0.7",
+ "version": "0.0.8",
"omschrijving": "",
"icon": "EyeOutline",
"required": [
@@ -5313,14 +5174,7 @@
"identifier": {
"description": "De identifier van deze View",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "id-a6ee6077d3094afa91fc6ea92a9a2a40",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5338,14 +5192,7 @@
"type": {
"description": "De type van deze View",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Diagram",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5364,14 +5211,7 @@
"viewpoint": {
"description": "De viewpoint van deze View",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Application Structure",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5389,14 +5229,7 @@
"name": {
"description": "De name van deze View",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "LV01 BGT basisregistratie en SVB view",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5415,14 +5248,7 @@
"nameLong": {
"description": "De name-language van deze View",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5440,14 +5266,7 @@
"documentation": {
"description": "De documentation van deze View",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Toont de referentiecomponenten ter ondersteuning van applicatieservices voor publieksdiensten",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5468,11 +5287,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5490,13 +5304,6 @@
"properties": {
"description": "De properties van deze View",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -5519,13 +5326,6 @@
"nodes": {
"description": "De nodes van deze View",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -5547,13 +5347,6 @@
"connections": {
"description": "De connections van deze View",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -5674,7 +5467,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -5688,7 +5481,7 @@
"slug": "model",
"title": "Model",
"description": "AMEF Model - Volledig ArchiMate model met alle elementen, relaties en views",
- "version": "0.0.43",
+ "version": "0.0.44",
"omschrijving": "",
"icon": "Database",
"required": [
@@ -5712,14 +5505,7 @@
"xmlns": {
"description": "De xmlns van dit GEMMA Model",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "http://www.opengroup.org/xsd/archimate/3.0/",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5737,14 +5523,7 @@
"xsi": {
"description": "De xsi van dit GEMMA Model",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "http://www.w3.org/2001/XMLSchema-instance",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5762,14 +5541,7 @@
"schemaLocation": {
"description": "De schemaLocation van dit GEMMA Model",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "http://www.opengroup.org/xsd/archimate/3.0/ http://www.opengroup.org/xsd/archimate/3.1/archimate3_Diagram.xsd",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5787,14 +5559,7 @@
"identifier": {
"description": "De identifier van dit GEMMA Model",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "id-b58b6b03-a59d-472b-bd87-88ba77ded4e6",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5812,14 +5577,7 @@
"name": {
"description": "De name van dit GEMMA Model",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "GEMMA release (test)",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5840,11 +5598,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5863,13 +5616,7 @@
"description": "De version van dit GEMMA Model",
"type": "string",
"minLength": 3,
- "maxLength": null,
"example": "3.0",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5887,14 +5634,7 @@
"documentation": {
"description": "De documentation van dit GEMMA Model",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "De GEMeentelijk Model Architectuur (GEMMA) bevat een blauwdruk van de gemeente en haar informatievoorziening. De GEMMA kan worden gebruikt als basis voor de projectmodellen",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5915,11 +5655,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -5937,13 +5672,7 @@
"properties": {
"description": "De properties van dit GEMMA Model",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
"minItems": 1,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -5966,13 +5695,7 @@
"elements": {
"description": "De elements van dit GEMMA Model",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
"minItems": 1,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -5995,13 +5718,7 @@
"relationships": {
"description": "De relationships van dit GEMMA Model",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
"minItems": 1,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -6024,13 +5741,7 @@
"organizations": {
"description": "De organizations van dit GEMMA Model",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
"minItems": 1,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -6053,13 +5764,7 @@
"propertyDefinitions": {
"description": "De propertyDefinitions van dit GEMMA Model",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
"minItems": 1,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -6082,13 +5787,7 @@
"views": {
"description": "De views van dit GEMMA Model",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
"minItems": 1,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -6126,7 +5825,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -6139,7 +5838,7 @@
"slug": "property-definition",
"title": "Property Definition",
"description": "AMEF Property Definition - Definitie van eigenschappen voor ArchiMate elementen",
- "version": "0.0.8",
+ "version": "0.0.9",
"omschrijving": "",
"icon": "CogOutline",
"required": [
@@ -6150,14 +5849,7 @@
"identifier": {
"description": "De identifier van deze Property Definition",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "propid-43",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6180,14 +5872,7 @@
"type": {
"description": "De type van deze Property Definition",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "string",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6211,14 +5896,7 @@
"name": {
"description": "De name van deze Property Definition",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "API-portaal",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6244,11 +5922,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6286,7 +5959,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -6299,7 +5972,7 @@
"slug": "relation",
"title": "Relation",
"description": "AMEF Relation - Relaties tussen architectuur elementen uit het ArchiMate model",
- "version": "0.0.8",
+ "version": "0.0.9",
"omschrijving": "",
"icon": "ArrowRight",
"required": [
@@ -6313,14 +5986,7 @@
"identifier": {
"description": "De identifier van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "id-1b46181d68e5477a9c0b5a95a0677924",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6343,14 +6009,7 @@
"source": {
"description": "De source van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "id-d143a1fc-02dc-11e6-11ba-005056a85f9c",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6373,14 +6032,7 @@
"target": {
"description": "De target van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "id-a85f22d89af14222a914fcb9ecfe6815",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6403,14 +6055,7 @@
"type": {
"description": "De type van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Access",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6434,14 +6079,7 @@
"accessType": {
"description": "De accessType van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Read",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6464,14 +6102,7 @@
"isDirected": {
"description": "De isDirected van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "true",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6494,14 +6125,7 @@
"name": {
"description": "De name van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Verplicht",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6527,11 +6151,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6554,14 +6173,7 @@
"documentation": {
"description": "De documentation van deze Relation",
"type": "string",
- "minLength": null,
- "maxLength": null,
"example": "Op basis van het zaaktype routeert de servicebuscomponent de aanvraag naar een Zaakafhandelcomponent (generiek of specifiek).",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6587,11 +6199,6 @@
"minLength": 2,
"maxLength": 2,
"example": "nl",
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"objectConfiguration": {
"handling": "nested-object",
@@ -6614,13 +6221,6 @@
"properties": {
"description": "De properties van deze Relation",
"type": "array",
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"$ref": "",
"items": {
"cascadeDelete": true,
@@ -6762,7 +6362,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -6792,7 +6392,7 @@
},
"title": "Application",
"description": "Een applicatie is een softwarecomponent (applicatie of systeemsoftware)",
- "version": "0.3.3",
+ "version": "0.3.4",
"omschrijving": "",
"icon": "Package",
"required": [
@@ -6936,16 +6536,9 @@
"type": "string",
"visible": true,
"order": 9,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
"table": {
"default": true
},
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"default": "Closed source",
@@ -6964,13 +6557,6 @@
"type": "string",
"visible": true,
"order": 10,
- "minLength": null,
- "maxLength": null,
- "minimum": null,
- "maximum": null,
- "multipleOf": null,
- "minItems": null,
- "maxItems": null,
"inversedBy": "",
"$ref": "",
"objectConfiguration": [],
@@ -7309,7 +6895,8 @@
"publicationDate": {
"$lte": "$now"
}
- }
+ },
+ "crossOrganisation": true
},
{
"group": "aanbod-beheerder",
@@ -7321,7 +6908,8 @@
"group": "public",
"match": {
"registeredBy": "Supplier"
- }
+ },
+ "crossOrganisation": true
}
]
},
@@ -7503,7 +7091,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -7633,7 +7221,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -7873,7 +7461,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -8042,7 +7630,7 @@
"groups": null,
"authorization": {
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
]
},
"configuration": {
@@ -8271,7 +7859,7 @@
"aanbod-beheerder"
],
"read": [
- "software-catalog-admins",
+ {"group": "software-catalog-admins", "crossOrganisation": true},
{
"group": "organisatie-beheerder",
"match": {
@@ -8306,7 +7894,8 @@
"group": "aanbod-beheerder",
"match": {
"provider": "$organisation"
- }
+ },
+ "crossOrganisation": true
}
],
"update": [
@@ -8620,7 +8209,7 @@
"aanbod-beheerder"
],
"read": [
- "public"
+ {"group": "public", "crossOrganisation": true}
],
"update": [
"software-catalog-admins",
diff --git a/openspec/changes/adopt-apphost/screens.md b/openspec/changes/adopt-apphost/screens.md
new file mode 100644
index 000000000..3baf9bf4b
--- /dev/null
+++ b/openspec/changes/adopt-apphost/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: Runs stackiq on the shared app host, a change under the pages.
diff --git a/openspec/changes/adopt-connection-registry/screens.md b/openspec/changes/adopt-connection-registry/screens.md
new file mode 100644
index 000000000..f51cb80ed
--- /dev/null
+++ b/openspec/changes/adopt-connection-registry/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkInstellingenUitwisseling https://identity.conduction.nl/screens/board?id=stackiq/SkInstellingenUitwisseling
diff --git a/openspec/changes/adopt-integration-leaves/screens.md b/openspec/changes/adopt-integration-leaves/screens.md
new file mode 100644
index 000000000..0ed634675
--- /dev/null
+++ b/openspec/changes/adopt-integration-leaves/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: Moves integrations onto the shared integration leaves; the pages stay as they are.
diff --git a/openspec/changes/applications-copy-with-links/proposal.md b/openspec/changes/applications-copy-with-links/proposal.md
new file mode 100644
index 000000000..9ee1ac6c5
--- /dev/null
+++ b/openspec/changes/applications-copy-with-links/proposal.md
@@ -0,0 +1,23 @@
+---
+kind: code
+depends_on: []
+---
+
+# Copy an application with its links ("Kopiëren met koppelingen")
+
+## Summary
+
+A user who starts a new application entry from an existing one gets the fields, but not the links that make it useful: the connections, the services that cover it, the reference components and the documents. nextcloud-vue's change `index-copy-with-relations` (all ten tasks ticked) adds a copy dialog that lists the source's links and lets the user tick what the copy takes along, switched on per page with `config.copy.include`. Nothing switches it on in stackiq: the Applications page is a custom page (`FacetedCatalogIndexView`) that embeds `CnIndexPage`, and its manifest entry has no `copy` config. Board SkApplicaties draws "Kopiëren" in the selection bar and the copy with its links. This change adopts the library feature on the Applications and Services pages.
+
+## Rows
+
+- `land-copy-entry` "Start a new application entry by copying an existing one with its links." State specified; this change is its stackiq plan.
+
+## Dependencies
+
+- nextcloud-vue `index-copy-with-relations` (done, awaiting archive): `CnCopyDialog` link list, `config.copy.include`, `useObjectCopy`.
+- OpenRegister object copy endpoint `POST /api/objects/{register}/{schema}/{id}/copy`: not on OpenRegister development on 30 Oct (the routes file has file copy and content-report copy only). Until it lands, the library shows the links read only and copies fields only, so task 3 (the live check) waits for it.
+
+## Screens
+
+See `screens.md`: SkApplicaties.
diff --git a/openspec/changes/applications-copy-with-links/screens.md b/openspec/changes/applications-copy-with-links/screens.md
new file mode 100644
index 000000000..4d5903306
--- /dev/null
+++ b/openspec/changes/applications-copy-with-links/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkApplicaties: "Kopiëren" in the selection bar and the copy with its links https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
diff --git a/openspec/changes/applications-copy-with-links/specs/application-copy/spec.md b/openspec/changes/applications-copy-with-links/specs/application-copy/spec.md
new file mode 100644
index 000000000..cfab8aa81
--- /dev/null
+++ b/openspec/changes/applications-copy-with-links/specs/application-copy/spec.md
@@ -0,0 +1,26 @@
+# application-copy Specification (delta)
+
+## Purpose
+
+Starting a new application or service entry from an existing one, links included. Covers row land-copy-entry.
+
+## ADDED Requirements
+
+### Requirement: An application is copied with the links the user picks (REQ-ACP-001)
+
+The Applications and Services pages SHALL offer Copy for one entry and for a selection. The copy dialog SHALL list the source's links grouped by kind (relation rows such as connections and reference components, objects that point at the source such as services, and attached files) and SHALL let the user tick which kinds the copy takes along. The copy SHALL link to the same objects as the source and SHALL NOT duplicate them. A copy SHALL be created in one request, so a failure leaves no half-linked copy, and SHALL start unpublished.
+
+#### Scenario: Copying an application with its connections
+- GIVEN the application "Mozard 2026.1" with three connections and two reference components
+- WHEN a user chooses Copy, names the copy "Mozard 2026.2" and ticks connections and reference components
+- THEN "Mozard 2026.2" exists with the same three connections and two reference components, unpublished, and the source is unchanged
+
+#### Scenario: Copying fields only
+- GIVEN the same dialog
+- WHEN the user ticks nothing
+- THEN the copy has the source's fields and no links
+
+#### Scenario: The copy endpoint is not available
+- GIVEN an OpenRegister without the object copy endpoint
+- WHEN the dialog opens
+- THEN the links are listed read only with a note that they are not copied yet, and Copy creates a fields-only copy
diff --git a/openspec/changes/applications-copy-with-links/tasks.md b/openspec/changes/applications-copy-with-links/tasks.md
new file mode 100644
index 000000000..5b21cf3f7
--- /dev/null
+++ b/openspec/changes/applications-copy-with-links/tasks.md
@@ -0,0 +1,30 @@
+# Tasks: applications-copy-with-links
+
+## Implementation tasks
+
+### Task 1: Switch the copy with links on (board SkApplicaties)
+- **spec_ref**: openspec/changes/applications-copy-with-links/specs/application-copy/spec.md#requirement-an-application-is-copied-with-the-links-the-user-picks-req-acp-001
+- **files**: `src/manifest.json`, `src/views/FacetedCatalogIndexView.vue`, `package.json`, `package-lock.json`, `tests/vitest/facetedIndexCopy.spec.js`
+- **acceptance_criteria**:
+ - GIVEN the Modules and Diensten pages WHEN read THEN their config has `copy.include: ["relationRows", "incoming", "files"]`
+ - GIVEN `FacetedCatalogIndexView` WHEN it renders its embedded `CnIndexPage` THEN it passes the page's `copy` config through, so single and mass copy show the link list
+ - GIVEN `@conduction/nextcloud-vue` WHEN installed THEN it is the release that ships `index-copy-with-relations`
+- [ ] Implement
+- [ ] Test (vitest `facetedIndexCopy.spec.js`)
+
+### Task 2: A copy starts unpublished
+- **spec_ref**: openspec/changes/applications-copy-with-links/specs/application-copy/spec.md#requirement-an-application-is-copied-with-the-links-the-user-picks-req-acp-001
+- **files**: `src/manifest.json`, `tests/vitest/facetedIndexCopy.spec.js`
+- **acceptance_criteria**:
+ - GIVEN a copy WHEN created THEN the overrides clear `publicationDate` and `depublicationDate` (the library's copy overrides config), so the copy is not published with the source
+- [ ] Implement
+- [ ] Test (vitest `facetedIndexCopy.spec.js`)
+
+### Task 3: Live check once OpenRegister's copy endpoint lands
+- **spec_ref**: openspec/changes/applications-copy-with-links/specs/application-copy/spec.md#requirement-an-application-is-copied-with-the-links-the-user-picks-req-acp-001
+- **files**: `tests/e2e/spec-coverage/application-copy.spec.ts`
+- **acceptance_criteria**:
+ - GIVEN an instance with the OpenRegister copy endpoint WHEN a user copies an application with connections ticked THEN the copy holds the same connections
+ - GIVEN an instance without it WHEN the dialog opens THEN the links show read only with the note
+- [ ] Implement
+- [ ] Test (Playwright `application-copy.spec.ts`)
diff --git a/openspec/changes/architecture-assistant-drafted-views/screens.md b/openspec/changes/architecture-assistant-drafted-views/screens.md
new file mode 100644
index 000000000..60e0627df
--- /dev/null
+++ b/openspec/changes/architecture-assistant-drafted-views/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkModelleren https://identity.conduction.nl/screens/board?id=stackiq/SkModelleren
diff --git a/openspec/changes/architecture-data-model-and-ggm/screens.md b/openspec/changes/architecture-data-model-and-ggm/screens.md
new file mode 100644
index 000000000..f35900aec
--- /dev/null
+++ b/openspec/changes/architecture-data-model-and-ggm/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkGemmaPlaat https://identity.conduction.nl/screens/board?id=stackiq/SkGemmaPlaat
+- SkModelleren https://identity.conduction.nl/screens/board?id=stackiq/SkModelleren
diff --git a/openspec/changes/architecture-decision-register/screens.md b/openspec/changes/architecture-decision-register/screens.md
new file mode 100644
index 000000000..60e0627df
--- /dev/null
+++ b/openspec/changes/architecture-decision-register/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkModelleren https://identity.conduction.nl/screens/board?id=stackiq/SkModelleren
diff --git a/openspec/changes/architecture-future-state-scenarios/screens.md b/openspec/changes/architecture-future-state-scenarios/screens.md
new file mode 100644
index 000000000..60e0627df
--- /dev/null
+++ b/openspec/changes/architecture-future-state-scenarios/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkModelleren https://identity.conduction.nl/screens/board?id=stackiq/SkModelleren
diff --git a/openspec/changes/architecture-process-mapping/screens.md b/openspec/changes/architecture-process-mapping/screens.md
new file mode 100644
index 000000000..60e0627df
--- /dev/null
+++ b/openspec/changes/architecture-process-mapping/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkModelleren https://identity.conduction.nl/screens/board?id=stackiq/SkModelleren
diff --git a/openspec/changes/architecture-reference-component-coverage/design.md b/openspec/changes/architecture-reference-component-coverage/design.md
index 057a7e4cf..671426ef4 100644
--- a/openspec/changes/architecture-reference-component-coverage/design.md
+++ b/openspec/changes/architecture-reference-component-coverage/design.md
@@ -41,15 +41,20 @@ The controller runs the organisation check before any query and fails closed, as
Rejected: computing coverage in the browser from OpenRegister facets on `usage.usedForReferenceComponents`. A facet has no bucket for a value no row holds, so gaps need the full component list anyway, and overlap needs the usages behind each count. The portfolio report moved the same kind of cross-register join to the backend for the same reason (its manifest note, `src/manifest.json:1040`).
-### D3. The page: summary, table and map
-
-`ReferenceComponentCoverage.vue` follows `PortfolioReport.vue`: the same organisation picker (:61), Refresh and Export CSV buttons, and a truncation notice. Under that:
-
-- a summary with the four counts and "N applications in use name no reference component",
-- a `CnDataTable` of components with columns name, state, applications and fillable, and quick filters All, Gaps, Fillable gaps and Overlap,
-- `CoverageViewMap.vue`: a picker of imported GEMMA views from `GET /api/views` (`appinfo/routes.php:185`), and the chosen view drawn read-only through `viewGraph.js` on `CnGraphCanvas` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnGraphCanvas/CnGraphCanvas.vue`, `readOnly` at :196). Every node whose element is a reference component gets its state: a border in `--color-error` (gap), `--color-success` (covered) or `--color-warning` (overlap), and a text badge with the count and the application names, so colour is never the only signal.
-
-The page is reached from a second card on the Reports page, "Reference component coverage", next to Portfolio rationalization. No menu entry is added (ADR-097).
+### D3. The page: the GEMMA plate
+
+The board (SkGemmaPlaat) draws the coverage as a plate: the reference components
+grouped as the GEMMA model groups them (the element's `grouping`), each tile with
+the applications that cover it and its state in words, and a border colour that
+repeats the state, so colour is never the only signal. `GemmaPlate.vue` has the
+portfolio report's organisation picker, a Download button for the CSV, a summary
+(components, covered, gaps with how many are fillable, overlap, and the usages
+that name no component), the filters All, Gaps, Fillable gaps and Overlap
+(`src/utils/referenceComponentCoverage.js`), and a detail panel for the chosen
+component. It is in the menu under Reports & Compliance, where the board puts
+GEMMA plates, as a child (ADR-097). Drawing the same states on an imported GEMMA
+view (`CoverageViewMap.vue`, Task 5) waits for `architecture-views-editor`'s
+read-only view rendering.
Rejected: drawing the map with `ViewService` enrichment (`include_gebruik`). It groups usages by the single field `elementRef` (`lib/Service/ViewService.php:914`), so a usage for three components lands on at most one node.
diff --git a/openspec/changes/architecture-reference-component-coverage/screens.md b/openspec/changes/architecture-reference-component-coverage/screens.md
new file mode 100644
index 000000000..c38406cf9
--- /dev/null
+++ b/openspec/changes/architecture-reference-component-coverage/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkGemmaPlaat https://identity.conduction.nl/screens/board?id=stackiq/SkGemmaPlaat
+- SkRapportages https://identity.conduction.nl/screens/board?id=stackiq/SkRapportages
diff --git a/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md b/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md
index 16516faba..b87c236b1 100644
--- a/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md
+++ b/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md
@@ -38,23 +38,23 @@ A municipality sees, per GEMMA reference component, which of its applications in
- **THEN** the response SHALL be 403
- **AND** no usage SHALL be read
-### Requirement: REQ-RCC-002 A coverage page SHALL list the components with filters for gaps and overlaps
+### Requirement: REQ-RCC-002 A GEMMA plate SHALL show the components with their applications, with filters for gaps and overlaps
-Stackiq SHALL offer a Reference component coverage page at `/reference-component-coverage` (page `ReferenceComponentCoverage`), reached from a card on the Reports page. After an organisation is picked it SHALL show the counts of gaps, fillable gaps, covered components and overlaps, the number of usages that name no component, and a table of components with their state and applications, with the quick filters All, Gaps, Fillable gaps and Overlap, and an Export CSV button. Without any reference component it SHALL say that the GEMMA model must be imported.
+Stackiq SHALL offer a GEMMA plate page at `/gemma-plaat` (page `GemmaPlate`, board SkGemmaPlaat), in the menu under Reports & Compliance. After an organisation is picked it SHALL show the counts of reference components, covered components, gaps (with how many are fillable) and overlaps, the number of usages that name no component, and every reference component grouped by its GEMMA grouping, each with the applications that cover it and its state in words, with the filters All, Gaps, Fillable gaps and Overlap. Choosing a component SHALL show its description, state, applications and, for a fillable gap, the applications in use that offer it. A Download button SHALL give the same rows as CSV. Without any reference component it SHALL say that the GEMMA model must be imported.
#### Scenario: An information manager filters on overlap
@e2e tests/e2e/workflows/reference-component-coverage.spec.ts
- **GIVEN** two reference component elements and two usages of different modules that both name the first, created by the test fixture through OpenRegister's objects API
-- **WHEN** a municipal information manager opens Reports, chooses Reference component coverage, picks the organisation and chooses the quick filter Overlap
-- **THEN** the table SHALL show the first component with both applications
-- **AND** the summary SHALL read one overlap and one gap
+- **WHEN** a municipal information manager opens the GEMMA plate, picks the organisation and chooses the filter Overlap
+- **THEN** the first component SHALL read overlap with both applications
+- **AND** the second component, a gap, SHALL no longer show
#### Scenario: The page explains an instance without GEMMA
-@e2e tests/e2e/workflows/reference-component-coverage.spec.ts
+@e2e exclude Needs an instance without the GEMMA model, which the shared e2e instance has; tests/Unit/Service/ReferenceComponentCoverageServiceTest.php asserts that without an element schema no component is read, which the page renders as "Import the GEMMA model to see coverage".
- **GIVEN** an instance with no reference component elements
-- **WHEN** a municipal information manager opens the coverage page and picks an organisation
+- **WHEN** a municipal information manager opens the GEMMA plate and picks an organisation
- **THEN** the page SHALL say that the GEMMA model must be imported to see coverage
### Requirement: REQ-RCC-003 The coverage SHALL be drawn on a GEMMA view as a map
diff --git a/openspec/changes/architecture-reference-component-coverage/tasks.md b/openspec/changes/architecture-reference-component-coverage/tasks.md
index b184fe133..b5a7f6b6a 100644
--- a/openspec/changes/architecture-reference-component-coverage/tasks.md
+++ b/openspec/changes/architecture-reference-component-coverage/tasks.md
@@ -10,8 +10,8 @@
- GIVEN two usages of one module WHEN the coverage is derived THEN they count as one application
- GIVEN a gap and a used module that declares it WHEN the coverage is derived THEN the gap is fillable
- GIVEN a usage with an unknown status WHEN the coverage is derived THEN it counts
-- [ ] Implement
-- [ ] Test (PHPUnit `ReferenceComponentCoverageDerivationTest`)
+- [x] Implement
+- [x] Test (PHPUnit `ReferenceComponentCoverageDerivationTest`)
### Task 2: Shared organisation check
- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation
@@ -19,8 +19,8 @@
- **acceptance_criteria**:
- GIVEN an admin, an ambtenaar, a member and a non-member WHEN the check runs THEN only the non-member is refused, as today
- GIVEN the portfolio report controller WHEN it runs THEN it calls the shared check and its existing tests stay green
-- [ ] Implement
-- [ ] Test (PHPUnit `OrganisationReportAccessTest`, existing `PortfolioReportControllerTest`)
+- [x] Implement
+- [x] Test (PHPUnit `OrganisationReportAccessTest`, existing `PortfolioReportControllerTest`)
### Task 3: Coverage service, controller and route
- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation
@@ -29,17 +29,17 @@
- GIVEN a user of another organisation WHEN the endpoint is called THEN it answers 403 before the service runs
- GIVEN an organisation WHEN the endpoint is called THEN usages and components are read with bounded limits and the payload has the shape of design D2
- GIVEN `format=csv` WHEN the endpoint is called THEN the same rows come back as CSV
-- [ ] Implement
-- [ ] Test (PHPUnit `ReferenceComponentCoverageControllerTest`, `ReferenceComponentCoverageServiceTest`)
+- [x] Implement
+- [x] Test (PHPUnit `ReferenceComponentCoverageControllerTest`, `ReferenceComponentCoverageServiceTest`)
-### Task 4: Coverage page and Reports card
-- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-coverage-page-shall-list-the-components-with-filters-for-gaps-and-overlaps
-- **files**: `src/manifest.d/reference-component-coverage.json`, `src/views/organisaties/ReferenceComponentCoverage.vue`, `src/customComponents.js`, `tests/e2e/workflows/reference-component-coverage.spec.ts`
+### Task 4: GEMMA plate page (board SkGemmaPlaat)
+- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-gemma-plate-shall-show-the-components-with-their-applications-with-filters-for-gaps-and-overlaps
+- **files**: `src/manifest.d/reference-component-coverage.json` (menu child of Reports & Compliance), `src/views/organisaties/GemmaPlate.vue`, `src/utils/referenceComponentCoverage.js`, `tests/vitest/referenceComponentCoverage.spec.js`, `src/customComponents.js`, `tests/e2e/workflows/reference-component-coverage.spec.ts`
- **acceptance_criteria**:
- GIVEN the Reports page WHEN it renders THEN it shows the card Reference component coverage next to Portfolio rationalization
- GIVEN a picked organisation WHEN the page loads THEN it shows the counts, the table and the four quick filters
- GIVEN no reference components WHEN the page loads THEN it says the GEMMA model must be imported
-- [ ] Implement
+- [x] Implement
- [ ] Test (Playwright `reference-component-coverage.spec.ts` overlap filter and empty model scenarios)
### Task 5: Coverage map on a GEMMA view
@@ -62,7 +62,7 @@
- [ ] Test (PHPUnit `PortfolioReportServiceOverlapTest`, Playwright portfolio overlap scenario)
### Task 7: Documentation and translations
-- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-coverage-page-shall-list-the-components-with-filters-for-gaps-and-overlaps
+- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-gemma-plate-shall-show-the-components-with-their-applications-with-filters-for-gaps-and-overlaps
- **files**: `docs/features/reference-component-coverage.md`, `docs/features/portfolio-rationalization.md`, `l10n/en.json`, `l10n/nl.json`
- **acceptance_criteria**:
- GIVEN the feature pages WHEN they are read THEN they show the coverage table, the map and the Overlap section, each in a screenshot
diff --git a/openspec/changes/architecture-round-trip-check/screens.md b/openspec/changes/architecture-round-trip-check/screens.md
new file mode 100644
index 000000000..ddf20a75c
--- /dev/null
+++ b/openspec/changes/architecture-round-trip-check/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkInstellingenGegevens https://identity.conduction.nl/screens/board?id=stackiq/SkInstellingenGegevens
diff --git a/openspec/changes/architecture-shared-usage-on-views/screens.md b/openspec/changes/architecture-shared-usage-on-views/screens.md
new file mode 100644
index 000000000..beb995091
--- /dev/null
+++ b/openspec/changes/architecture-shared-usage-on-views/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkGemmaPlaat https://identity.conduction.nl/screens/board?id=stackiq/SkGemmaPlaat
diff --git a/openspec/changes/architecture-views-editor/screens.md b/openspec/changes/architecture-views-editor/screens.md
new file mode 100644
index 000000000..60e0627df
--- /dev/null
+++ b/openspec/changes/architecture-views-editor/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkModelleren https://identity.conduction.nl/screens/board?id=stackiq/SkModelleren
diff --git a/openspec/changes/architecture-views-to-office-documents/screens.md b/openspec/changes/architecture-views-to-office-documents/screens.md
new file mode 100644
index 000000000..beb995091
--- /dev/null
+++ b/openspec/changes/architecture-views-to-office-documents/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkGemmaPlaat https://identity.conduction.nl/screens/board?id=stackiq/SkGemmaPlaat
diff --git a/openspec/changes/cmdb-import-owner-identity/.openspec.yaml b/openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/.openspec.yaml
similarity index 100%
rename from openspec/changes/cmdb-import-owner-identity/.openspec.yaml
rename to openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/.openspec.yaml
diff --git a/openspec/changes/cmdb-import-owner-identity/proposal.md b/openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/proposal.md
similarity index 100%
rename from openspec/changes/cmdb-import-owner-identity/proposal.md
rename to openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/proposal.md
diff --git a/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md b/openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/specs/cmdb-export-import/spec.md
similarity index 100%
rename from openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md
rename to openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/specs/cmdb-export-import/spec.md
diff --git a/openspec/changes/cmdb-import-owner-identity/tasks.md b/openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/tasks.md
similarity index 100%
rename from openspec/changes/cmdb-import-owner-identity/tasks.md
rename to openspec/changes/archive/2026-10-30-cmdb-import-owner-identity/tasks.md
diff --git a/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/proposal.md b/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/proposal.md
new file mode 100644
index 000000000..6ede4c499
--- /dev/null
+++ b/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/proposal.md
@@ -0,0 +1,39 @@
+---
+kind: code
+---
+
+# Register your organisation from the municipality website
+
+## Summary
+
+Board SkPubliek has, beside the public catalogue, a form "Uw organisatie
+aanmelden": a supplier or a government organisation registers without an
+account, an administrator reviews it, and only then is the organisation active.
+The decision (coordinator, 23 Oct) is that this form is portaliq's: a form on
+the portal page that saves into stackiq's organisation schema with
+`registrationStatus: pending` fixed on the server. This change is stackiq's half.
+
+## What changes
+
+- An organisation created without a signed-in user is always stored with
+ `registrationStatus: pending` and without a publication date, whatever the
+ request says. A save listener on OpenRegister's creating event enforces it, so
+ the rule holds for portaliq's form, for stackiq's own intake endpoint and for a
+ direct call to OpenRegister's objects API alike. Until now only stackiq's
+ intake endpoint set it, and the organisation schema already allows an
+ anonymous create.
+- The organisation schema gets what the form asks: `website`, the
+ `registrationNumber` (KvK number or OIN), and the registering person's `registrationContactName` and `registrationContactEmail`,
+ readable only by signed-in users, like `registrationStatus` itself.
+- The existing moderation queue (`ModerationService`, `registrationStatus`
+ pending to active) reviews these registrations unchanged.
+
+## Out of scope
+
+- The form itself, its page and its fields: portaliq change
+ `site-organisation-registration-form`.
+- The activation mail after approval.
+
+## Board
+
+SkPubliek.
diff --git a/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/specs/organisation-registration/spec.md b/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/specs/organisation-registration/spec.md
new file mode 100644
index 000000000..441a60519
--- /dev/null
+++ b/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/specs/organisation-registration/spec.md
@@ -0,0 +1,33 @@
+# organisation-registration specification
+
+## ADDED Requirements
+
+### Requirement: REQ-ORR-001 An organisation registered without an account is pending until reviewed
+
+An organisation created while no user is signed in SHALL be stored with `registrationStatus` `pending` and without a `publicationDate`, whatever values the request carries, on every path that creates it through OpenRegister. A signed-in user's create, and a create from the command line (installs, imports and seed data), SHALL keep the values it sends.
+
+#### Scenario: A supplier registers from the municipality website
+@e2e exclude The form is portaliq's; tests/Unit/EventListener/AnonymousRegistrationListenerTest.php asserts the stored values and portaliq's site test the form.
+
+- **GIVEN** a visitor who is not signed in
+- **WHEN** they register "Open Kaart BV" as a supplier through the portal form
+- **THEN** the organisation is stored with `registrationStatus` `pending` and no publication date
+- **AND** it appears in the moderation queue
+
+#### Scenario: A crafted request cannot activate itself
+@e2e exclude A request crafted outside the browser; tests/Unit/EventListener/AnonymousRegistrationListenerTest.php sends `registrationStatus: active` and a publication date without a user and asserts both are replaced.
+
+- **GIVEN** a request without a session that posts an organisation with `registrationStatus` `active` and a publication date
+- **WHEN** OpenRegister creates it
+- **THEN** it is stored as `pending` without a publication date
+
+### Requirement: REQ-ORR-002 The organisation keeps what the registration form asks
+
+The organisation schema SHALL hold `website`, `registrationNumber` (a KvK number or an OIN), `registrationContactName` and `registrationContactEmail`. The registration number and the two contact fields SHALL be readable only by signed-in users.
+
+#### Scenario: An anonymous reader does not see who registered
+@e2e exclude A property read rule enforced by OpenRegister; tests/Unit/Settings/OrganisationRegistrationFragmentTest.php asserts the rule on the merged schema.
+
+- **GIVEN** an active organisation registered with a contact e-mail address
+- **WHEN** an anonymous visitor reads it
+- **THEN** the contact name and e-mail address are not in the answer
diff --git a/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/tasks.md b/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/tasks.md
new file mode 100644
index 000000000..85169542d
--- /dev/null
+++ b/openspec/changes/archive/2026-10-30-organisation-registration-through-the-portal/tasks.md
@@ -0,0 +1,4 @@
+# Tasks: organisation-registration-through-the-portal
+
+- [x] **T1**: `lib/EventListener/AnonymousRegistrationListener.php` on OpenRegister's `ObjectCreatingEvent`: an organisation created with no signed-in user gets `registrationStatus: pending` and no `publicationDate`. Registered in `lib/AppInfo/Application.php`. Test: PHPUnit `tests/Unit/EventListener/AnonymousRegistrationListenerTest.php`.
+- [x] **T2**: `lib/Settings/register.d/organisation-registration.json`: `website`, `registrationNumber`, `registrationContactName`, `registrationContactEmail` on `organization`, the number and contact fields readable by signed-in users only, schema version raised. Test: PHPUnit `tests/Unit/Settings/OrganisationRegistrationFragmentTest.php`.
diff --git a/openspec/changes/bio-measures-shipped-and-not-met/proposal.md b/openspec/changes/bio-measures-shipped-and-not-met/proposal.md
new file mode 100644
index 000000000..81c519657
--- /dev/null
+++ b/openspec/changes/bio-measures-shipped-and-not-met/proposal.md
@@ -0,0 +1,26 @@
+---
+kind: code
+depends_on: []
+---
+
+# BIO measures ship with the app, show their level, and a claim can say "does not meet"
+
+## Summary
+
+The BIO measures page exists, but its level column reads `bbnNiveau` while the schema property is `bbnLevel`, so the column is empty (the level only shows on the detail page). The BIO 2.0 measures themselves are not shipped: only the mock register holds a few, so an administrator has to type them in. On the assessment side, a compliance claim can only say an application meets a measure. There is no way to record that it does not, so "does not meet" cannot be told apart from "not assessed", and the compliance column `bioMaatregel` differs from the property `bioMeasure`. Boards SkStandaarden and SkComplianceMatrix draw the level, and the states Geverifieerd, Geclaimd, Voldoet niet and Onbekend. The board is the canon (decision 130), so the two rows move from built to building with this plan.
+
+## Rows
+
+- `comp-bio-measures` "Browse the BIO information security measures with their theme and level."
+- `comp-bio-assessment` "Record which BIO measures an application meets and which it does not."
+
+## What changes
+
+- The BIO measures index reads `bbnLevel`.
+- A register fragment ships the BIO 2.0 measures (code, name, theme, BBN level, source), imported on install and upgrade without duplicating measures an administrator already entered (matched on code).
+- `compliancy` gets a `result` property: `meets`, `does-not-meet` (default `meets` for existing claims). The compliance matrix and the application's compliance list show four states: verified (meets with third-party evidence), claimed (meets without), does not meet, and unknown (no claim).
+- The matrix column key `bioMaatregel` becomes `bioMeasure`.
+
+## Screens
+
+See `screens.md`: SkStandaarden, SkComplianceMatrix.
diff --git a/openspec/changes/bio-measures-shipped-and-not-met/screens.md b/openspec/changes/bio-measures-shipped-and-not-met/screens.md
new file mode 100644
index 000000000..db82fc9e5
--- /dev/null
+++ b/openspec/changes/bio-measures-shipped-and-not-met/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkStandaarden: the BIO measures with theme and BBN level https://identity.conduction.nl/screens/board?id=stackiq/SkStandaarden
+- SkComplianceMatrix: the states Geverifieerd, Geclaimd, Voldoet niet and Onbekend https://identity.conduction.nl/screens/board?id=stackiq/SkComplianceMatrix
diff --git a/openspec/changes/bio-measures-shipped-and-not-met/specs/bio-compliance-assessment/spec.md b/openspec/changes/bio-measures-shipped-and-not-met/specs/bio-compliance-assessment/spec.md
new file mode 100644
index 000000000..1502a3190
--- /dev/null
+++ b/openspec/changes/bio-measures-shipped-and-not-met/specs/bio-compliance-assessment/spec.md
@@ -0,0 +1,36 @@
+# bio-compliance-assessment Specification (delta)
+
+## ADDED Requirements
+
+### Requirement: The BIO 2.0 measures ship with the app and show their level (REQ-BIO-010)
+
+stackiq SHALL ship the BIO 2.0 measures as reference data (code, name, description, theme, BBN level, BIO version, source) and import them on install and upgrade. A measure with the same code that already exists SHALL be kept, not duplicated. The BIO measures page SHALL show code, name, theme, BIO version and BBN level.
+
+#### Scenario: A fresh install
+- GIVEN a new instance with the catalogue register imported
+- WHEN an administrator opens BIO measures
+- THEN the BIO 2.0 measures are listed, each with its theme and BBN level
+
+#### Scenario: An upgrade with hand-entered measures
+- GIVEN an administrator typed in measure 9.4.1 before
+- WHEN the app upgrades
+- THEN there is still one measure 9.4.1, with the administrator's text
+
+### Requirement: A compliance claim says whether the application meets the measure (REQ-BIO-011)
+
+A compliance claim on a BIO measure or standard SHALL record a result, meets or does not meet. The compliance matrix and an application's compliance list SHALL show four states: verified (meets, with third-party evidence), claimed (meets, without), does not meet, and unknown (no claim). The summary SHALL count the cells that do not meet.
+
+#### Scenario: Recording a measure that is not met
+- GIVEN an application without encryption at rest
+- WHEN its owner adds a claim on measure 10.1.1 with result "does not meet"
+- THEN the matrix cell shows Does not meet, and the summary counts it
+
+#### Scenario: Not assessed stays apart
+- GIVEN an application with no claim on 9.4.1
+- WHEN the matrix renders
+- THEN the cell shows Unknown, not Does not meet
+
+#### Scenario: Existing claims
+- GIVEN claims made before this change
+- WHEN the app upgrades
+- THEN each has result "meets" and shows as verified or claimed as before
diff --git a/openspec/changes/bio-measures-shipped-and-not-met/tasks.md b/openspec/changes/bio-measures-shipped-and-not-met/tasks.md
new file mode 100644
index 000000000..8e4d34a68
--- /dev/null
+++ b/openspec/changes/bio-measures-shipped-and-not-met/tasks.md
@@ -0,0 +1,38 @@
+# Tasks: bio-measures-shipped-and-not-met
+
+## Implementation tasks
+
+### Task 1: Level column on the BIO measures page (board SkStandaarden)
+- **spec_ref**: openspec/changes/bio-measures-shipped-and-not-met/specs/bio-compliance-assessment/spec.md#requirement-the-bio-20-measures-ship-with-the-app-and-show-their-level-req-bio-010
+- **files**: `src/manifest.json`, `tests/vitest/bioMeasuresPage.spec.js`
+- **acceptance_criteria**:
+ - GIVEN the BioMaatregelen page WHEN read THEN its columns are code, name, thema, bioVersion and bbnLevel, and the detail `_note` names `bbnLevel`
+- [ ] Implement
+- [ ] Test (vitest `bioMeasuresPage.spec.js`)
+
+### Task 2: Ship the BIO 2.0 measures
+- **spec_ref**: openspec/changes/bio-measures-shipped-and-not-met/specs/bio-compliance-assessment/spec.md#requirement-the-bio-20-measures-ship-with-the-app-and-show-their-level-req-bio-010
+- **files**: `lib/Settings/register.d/bio-measures.json`, `lib/Settings/register.d/README.md`, `tests/Unit/Settings/BioMeasuresFragmentTest.php`
+- **acceptance_criteria**:
+ - GIVEN the fragment WHEN read THEN every BIO 2.0 measure has code, name, thema, bbnLevel, bioVersion "2.0" and bron with the source URL
+ - GIVEN an existing measure with the same code WHEN the register imports THEN it is not duplicated or overwritten (objects keyed on code)
+- [ ] Implement
+- [ ] Test (PHPUnit `BioMeasuresFragmentTest`)
+
+### Task 3: A result on the compliance claim
+- **spec_ref**: openspec/changes/bio-measures-shipped-and-not-met/specs/bio-compliance-assessment/spec.md#requirement-a-compliance-claim-says-whether-the-application-meets-the-measure-req-bio-011
+- **files**: `lib/Settings/softwarecatalogus_register.json`, `lib/Repair/DefaultComplianceResultToMeets.php`, `appinfo/info.xml`, `tests/Unit/Repair/DefaultComplianceResultToMeetsTest.php`
+- **acceptance_criteria**:
+ - GIVEN the compliancy schema WHEN read THEN it has `result` with enum meets, does-not-meet and default meets
+ - GIVEN claims without a result WHEN the repair step runs THEN each gets "meets", and a second run changes nothing
+- [ ] Implement
+- [ ] Test (PHPUnit `DefaultComplianceResultToMeetsTest`)
+
+### Task 4: Four states in the matrix and the compliance list (board SkComplianceMatrix)
+- **spec_ref**: openspec/changes/bio-measures-shipped-and-not-met/specs/bio-compliance-assessment/spec.md#requirement-a-compliance-claim-says-whether-the-application-meets-the-measure-req-bio-011
+- **files**: `src/views/ComplianceMatrixView.vue`, `src/manifest.json`, `tests/vitest/complianceMatrixStates.spec.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN claims with and without evidence and with result does-not-meet WHEN the matrix renders THEN cells show Verified, Claimed, Does not meet or Unknown with the legend, and the summary counts Does not meet
+ - GIVEN the md-compliance list WHEN it renders THEN it has a Result column, and the matrix reads `bioMeasure`, not `bioMaatregel`
+- [ ] Implement
+- [ ] Test (vitest `complianceMatrixStates.spec.js`)
diff --git a/openspec/changes/bound-unbounded-searchobjects-scans/screens.md b/openspec/changes/bound-unbounded-searchobjects-scans/screens.md
new file mode 100644
index 000000000..c60ad17de
--- /dev/null
+++ b/openspec/changes/bound-unbounded-searchobjects-scans/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: Puts limits on background searches; nothing on a page changes.
diff --git a/openspec/changes/bundle-hygiene-apexcharts-lodash/screens.md b/openspec/changes/bundle-hygiene-apexcharts-lodash/screens.md
new file mode 100644
index 000000000..47cd08ea6
--- /dev/null
+++ b/openspec/changes/bundle-hygiene-apexcharts-lodash/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: Trims the frontend bundle; nothing on a page changes.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/screens.md b/openspec/changes/cmdb-import-archive-reconciliation/screens.md
new file mode 100644
index 000000000..1f419cc43
--- /dev/null
+++ b/openspec/changes/cmdb-import-archive-reconciliation/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkCmdb https://identity.conduction.nl/screens/board?id=stackiq/SkCmdb
diff --git a/openspec/changes/cmdb-import-mapping-view/screens.md b/openspec/changes/cmdb-import-mapping-view/screens.md
new file mode 100644
index 000000000..1662080cd
--- /dev/null
+++ b/openspec/changes/cmdb-import-mapping-view/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: The column mapping is shown and edited in OpenRegister's pack editor, not on a stackiq page.
diff --git a/openspec/changes/connections-api-catalogue/screens.md b/openspec/changes/connections-api-catalogue/screens.md
new file mode 100644
index 000000000..485a297d1
--- /dev/null
+++ b/openspec/changes/connections-api-catalogue/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkApplicatie https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
diff --git a/openspec/changes/connections-derived-dependencies/screens.md b/openspec/changes/connections-derived-dependencies/screens.md
new file mode 100644
index 000000000..7a60abdfb
--- /dev/null
+++ b/openspec/changes/connections-derived-dependencies/screens.md
@@ -0,0 +1,5 @@
+# Screens
+
+- SkApplicatie https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
+- SkGebruikDetail https://identity.conduction.nl/screens/board?id=stackiq/SkGebruikDetail
+- SkModuleversie https://identity.conduction.nl/screens/board?id=stackiq/SkModuleversie
diff --git a/openspec/changes/connections-diagram-and-graph-export/screens.md b/openspec/changes/connections-diagram-and-graph-export/screens.md
new file mode 100644
index 000000000..ce45d0fa1
--- /dev/null
+++ b/openspec/changes/connections-diagram-and-graph-export/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkKoppelingen https://identity.conduction.nl/screens/board?id=stackiq/SkKoppelingen
diff --git a/openspec/changes/contract-approval-ownership-guard/screens.md b/openspec/changes/contract-approval-ownership-guard/screens.md
new file mode 100644
index 000000000..5cff67019
--- /dev/null
+++ b/openspec/changes/contract-approval-ownership-guard/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: An access check on contract approval; the contract page is unchanged.
diff --git a/openspec/changes/contracts-expiry-and-owner/screens.md b/openspec/changes/contracts-expiry-and-owner/screens.md
new file mode 100644
index 000000000..c4fdc95d5
--- /dev/null
+++ b/openspec/changes/contracts-expiry-and-owner/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkContract https://identity.conduction.nl/screens/board?id=stackiq/SkContract
+- SkContracten https://identity.conduction.nl/screens/board?id=stackiq/SkContracten
diff --git a/openspec/changes/contracts-expiry-warning-fires/screens.md b/openspec/changes/contracts-expiry-warning-fires/screens.md
new file mode 100644
index 000000000..18f53c48a
--- /dev/null
+++ b/openspec/changes/contracts-expiry-warning-fires/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkContracten https://identity.conduction.nl/screens/board?id=stackiq/SkContracten
+- SkDashboard https://identity.conduction.nl/screens/board?id=stackiq/SkDashboard
diff --git a/openspec/changes/features-roadmap-page/proposal.md b/openspec/changes/features-roadmap-page/proposal.md
new file mode 100644
index 000000000..19c4f33d2
--- /dev/null
+++ b/openspec/changes/features-roadmap-page/proposal.md
@@ -0,0 +1,24 @@
+---
+kind: code
+depends_on: []
+---
+
+# The Features and roadmap page, with the store as its Uitbreidingen tab
+
+## Summary
+
+stackiq has a Features and roadmap page (`/features-roadmap`, footer menu) that lists what the app can do now, what is in beta and what is coming, fed by `openspec/features.overlay.json`. No requirement says it, so row `ins-feature-roadmap` cites nothing. Board SkFunctiesRoadmap draws the same page with a second tab, Uitbreidingen: the plug-ins from the store, with what each does and whether it is installed. In the app the store is a separate page (`/store`) behind its own footer entry. The board is the canon (decision 130), so this change writes the requirement for the page and builds the tab.
+
+## Rows
+
+- `ins-feature-roadmap` "See in the app which features are available, in beta or coming soon." Built: `src/manifest.json` FeaturesRoadmap page (type roadmap) and FeaturesRoadmapMenu; `openspec/features.overlay.json` (15 stable, 2 beta, 3 soon). Requirement written after the fact.
+- `ops-plugins` (requirement REQ-PLAT-007 in `platform-properties`): the visible half drawn on SkFunctiesRoadmap, the Uitbreidingen tab, is built here.
+
+## What changes
+
+- A `features-roadmap` capability with two requirements.
+- The Features and roadmap page gets two tabs: "Functies en roadmap" (the current content) and "Uitbreidingen" (the store list for stackiq, with a count). The footer Store entry opens the same page on the Uitbreidingen tab, so there is one place to look.
+
+## Screens
+
+See `screens.md`: SkFunctiesRoadmap.
diff --git a/openspec/changes/features-roadmap-page/screens.md b/openspec/changes/features-roadmap-page/screens.md
new file mode 100644
index 000000000..587d79311
--- /dev/null
+++ b/openspec/changes/features-roadmap-page/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkFunctiesRoadmap: the Functies en roadmap tab with Beschikbaar, Bèta and Binnenkort, and the Uitbreidingen tab with plug-ins from the store https://identity.conduction.nl/screens/board?id=stackiq/SkFunctiesRoadmap
diff --git a/openspec/changes/features-roadmap-page/specs/features-roadmap/spec.md b/openspec/changes/features-roadmap-page/specs/features-roadmap/spec.md
new file mode 100644
index 000000000..e07638f5b
--- /dev/null
+++ b/openspec/changes/features-roadmap-page/specs/features-roadmap/spec.md
@@ -0,0 +1,40 @@
+# features-roadmap Specification (delta)
+
+## Purpose
+
+The page where a user sees what stackiq can do, what is in beta, what is coming and which extensions can be added. Covers rows ins-feature-roadmap and the visible half of ops-plugins.
+
+## ADDED Requirements
+
+### Requirement: The Features and roadmap page groups the app's features by status (REQ-FRM-001)
+
+The Features and roadmap page SHALL list the features from `openspec/features.overlay.json` in three groups, Available (stable), Beta and Coming soon, each with its count, title and summary in the user's language. It SHALL be reachable from the footer menu and SHALL link to the documentation.
+
+#### Scenario: A user sees what is coming
+- GIVEN the overlay lists three features with status soon
+- WHEN a user opens Features and roadmap
+- THEN the Coming soon group shows those three with their titles and a count of 3
+
+#### Scenario: Dutch titles
+- GIVEN the user's language is Dutch
+- WHEN the page loads
+- THEN each feature shows its `title_nl` and `summary_nl`
+
+### Requirement: The Uitbreidingen tab lists the extensions from the store (REQ-FRM-002)
+
+The page SHALL have a second tab, Uitbreidingen, with a count, that lists the store items for stackiq (configuration sets and flows) with name, publisher, what it does and status (installed or install). An administrator SHALL be able to install an item from the tab; other users SHALL see the list without the install action. The footer Store entry SHALL open this tab.
+
+#### Scenario: An administrator installs an extension
+- GIVEN the store offers a stackiq flow that is not installed
+- WHEN an administrator opens Uitbreidingen and chooses Install on it
+- THEN the item shows as installed and the flow appears on the Flows page
+
+#### Scenario: A user without admin rights
+- GIVEN a signed-in user who is not an administrator
+- WHEN they open Uitbreidingen
+- THEN the items are listed and no install action is offered
+
+#### Scenario: The footer Store entry
+- GIVEN the footer menu
+- WHEN a user chooses Store
+- THEN Features and roadmap opens on the Uitbreidingen tab
diff --git a/openspec/changes/features-roadmap-page/tasks.md b/openspec/changes/features-roadmap-page/tasks.md
new file mode 100644
index 000000000..5e733971c
--- /dev/null
+++ b/openspec/changes/features-roadmap-page/tasks.md
@@ -0,0 +1,32 @@
+# Tasks: features-roadmap-page
+
+## Implementation tasks
+
+### Task 1: Tabs on the Features and roadmap page (board SkFunctiesRoadmap)
+- **spec_ref**: openspec/changes/features-roadmap-page/specs/features-roadmap/spec.md#requirement-the-uitbreidingen-tab-lists-the-extensions-from-the-store-req-frm-002
+- **files**: `src/manifest.json`, `src/views/FeaturesRoadmapTabs.vue`, `src/customComponents.js`, `tests/vitest/featuresRoadmapTabs.spec.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN the FeaturesRoadmap page WHEN it renders THEN it shows the tabs "Features and roadmap" and "Extensions" with the store item count on the second
+ - GIVEN the Extensions tab WHEN it renders THEN it shows the store list the Store page shows today (the nextcloud-vue store component with `app: stackiq` and the store types from `src/manifest.json`), with name, publisher, description and status
+ - GIVEN a non-admin user WHEN the tab renders THEN no install action is shown
+ - GIVEN the route `/features-roadmap?tab=extensions` WHEN opened THEN the Extensions tab is active
+- [ ] Implement
+- [ ] Test (vitest `featuresRoadmapTabs.spec.js`)
+
+### Task 2: One entry for the store
+- **spec_ref**: openspec/changes/features-roadmap-page/specs/features-roadmap/spec.md#requirement-the-uitbreidingen-tab-lists-the-extensions-from-the-store-req-frm-002
+- **files**: `src/manifest.json`, `tests/validate-manifest.js`
+- **acceptance_criteria**:
+ - GIVEN StoreMenu WHEN chosen THEN it routes to FeaturesRoadmap with `tab=extensions`
+ - GIVEN `/store` WHEN opened from an old link THEN it redirects to the Extensions tab
+- [ ] Implement
+- [ ] Test (`tests/validate-manifest.js`, Playwright `tests/e2e/spec-coverage/features-roadmap.spec.ts`)
+
+### Task 3: Tag and test the status groups
+- **spec_ref**: openspec/changes/features-roadmap-page/specs/features-roadmap/spec.md#requirement-the-features-and-roadmap-page-groups-the-apps-features-by-status-req-frm-001
+- **files**: `src/manifest.json`, `tests/vitest/featuresOverlay.spec.js`
+- **acceptance_criteria**:
+ - GIVEN `openspec/features.overlay.json` WHEN the vitest reads it THEN every entry has slug, title, title_nl, summary, summary_nl and a status of stable, beta or soon
+ - GIVEN the FeaturesRoadmap page WHEN read THEN its `_note` cites REQ-FRM-001
+- [ ] Implement
+- [ ] Test (vitest `featuresOverlay.spec.js`)
diff --git a/openspec/changes/federation-pull-fetches-the-peer/proposal.md b/openspec/changes/federation-pull-fetches-the-peer/proposal.md
new file mode 100644
index 000000000..ae0b9f71a
--- /dev/null
+++ b/openspec/changes/federation-pull-fetches-the-peer/proposal.md
@@ -0,0 +1,24 @@
+---
+kind: code
+depends_on: []
+---
+
+# The federation pull fetches the peer, and mirrored entries show where they came from
+
+## Summary
+
+`FederationService::pullPeer()` is meant to mirror a peer catalogue's published entries. Its fetch (`fetchPeerCatalog()`, `lib/Service/Federation/FederationService.php:428`) calls OpenCatalogi's `DirectoryService::getDirectory(['url' => $peerUrl])`, and OpenCatalogi ignores that parameter, so what gets mirrored is the local directory listing, not the peer. Mirrors are stamped with `_source.instance`, but no page shows it. Board SkInstellingenUitwisseling draws the peers table (catalogue, address, last pulled, objects, an error state "Fout: niet bereikbaar", Nu ophalen) and the provenance line "Uit: Softwarecatalogus Drechtsteden". The board is the canon (decision 130), so the row moves from built to building with this plan.
+
+## Rows
+
+- `share-federation-pull` "Pull published entries from peer catalogues, marked with where they came from."
+
+## What changes
+
+- The pull fetches the peer's public published listing over HTTP through Nextcloud's `IClientService`, after the existing SSRF guard, page by page with a cap, and hands those entries to the existing `FederationMerger::plan()`.
+- The peers table shows last pulled, object count and the last error per peer.
+- A mirrored entry shows "From: " with a link on its detail page and a badge on the list, and stays read only.
+
+## Screens
+
+See `screens.md`: SkInstellingenUitwisseling, SkApplicatie.
diff --git a/openspec/changes/federation-pull-fetches-the-peer/screens.md b/openspec/changes/federation-pull-fetches-the-peer/screens.md
new file mode 100644
index 000000000..cd771e4e4
--- /dev/null
+++ b/openspec/changes/federation-pull-fetches-the-peer/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkInstellingenUitwisseling: Catalogusfederatie with the peers table, Nu ophalen and the Herkomst line https://identity.conduction.nl/screens/board?id=stackiq/SkInstellingenUitwisseling
+- SkApplicatie: an application page, where a mirrored entry shows its source https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
diff --git a/openspec/changes/federation-pull-fetches-the-peer/specs/federated-catalog-sync/spec.md b/openspec/changes/federation-pull-fetches-the-peer/specs/federated-catalog-sync/spec.md
new file mode 100644
index 000000000..695e1d887
--- /dev/null
+++ b/openspec/changes/federation-pull-fetches-the-peer/specs/federated-catalog-sync/spec.md
@@ -0,0 +1,31 @@
+# federated-catalog-sync Specification (delta)
+
+## ADDED Requirements
+
+### Requirement: A pull reads the peer's own published catalogue (REQ-FED-010)
+
+Pulling a peer SHALL fetch that peer's public listing of published entries over HTTP at the peer's address, after the SSRF guard allows the host, following pages up to a configured cap. Only those entries SHALL be merged. A fetch that fails SHALL count as a failure for that peer and SHALL NOT change its mirrors.
+
+#### Scenario: Pulling a reachable peer
+- GIVEN the peer catalogus.drechtsteden.nl publishes 1,204 entries
+- WHEN an administrator chooses Pull now
+- THEN 1,204 mirrors exist for that peer, and none of them is a local entry
+
+#### Scenario: An unreachable peer
+- GIVEN the peer apps.wshd.nl does not answer
+- WHEN the scheduled pull runs
+- THEN its mirrors are unchanged, its failure count rises, and the peers table shows "Error: not reachable"
+
+#### Scenario: A blocked host
+- GIVEN a peer address that resolves to a private network
+- WHEN a pull runs
+- THEN no request is made and the peer shows as blocked
+
+### Requirement: Mirrored entries show where they came from (REQ-FED-011)
+
+The peers table SHALL show per peer its name, address, last successful pull, number of mirrored objects and last error. A mirrored entry SHALL show "From: " with a link to the entry at the peer on its detail page and a source badge in lists, and SHALL offer no edit or delete action.
+
+#### Scenario: A user opens a mirrored application
+- GIVEN an application mirrored from Softwarecatalogus Drechtsteden
+- WHEN a user opens it
+- THEN the page says "From: Softwarecatalogus Drechtsteden" with a link to the original, and the edit action is not offered
diff --git a/openspec/changes/federation-pull-fetches-the-peer/tasks.md b/openspec/changes/federation-pull-fetches-the-peer/tasks.md
new file mode 100644
index 000000000..06e36b1b2
--- /dev/null
+++ b/openspec/changes/federation-pull-fetches-the-peer/tasks.md
@@ -0,0 +1,31 @@
+# Tasks: federation-pull-fetches-the-peer
+
+## Implementation tasks
+
+### Task 1: Fetch the peer over HTTP
+- **spec_ref**: openspec/changes/federation-pull-fetches-the-peer/specs/federated-catalog-sync/spec.md#requirement-a-pull-reads-the-peers-own-published-catalogue-req-fed-010
+- **files**: `lib/Service/Federation/FederationService.php`, `lib/Service/Federation/PeerCatalogClient.php`, `lib/Service/Federation/FederationConfig.php`, `tests/Unit/Service/PeerCatalogClientTest.php`, `tests/Unit/Service/FederationServiceTest.php`
+- **acceptance_criteria**:
+ - GIVEN `fetchPeerCatalog()` WHEN called THEN it uses `PeerCatalogClient` (Nextcloud `IClientService`) to GET the peer's public published listing, follows pages up to `federation_max_entries` and returns the peer's organisation name and entries; `DirectoryService::getDirectory()` is no longer called for a pull
+ - GIVEN a timeout, non-2xx or invalid JSON WHEN fetching THEN the result is `ok: false` with a reason, and `recordPullFailure()` runs
+ - GIVEN a host the SSRF guard blocks WHEN pulling THEN no HTTP request is made
+- [ ] Implement
+- [ ] Test (PHPUnit `PeerCatalogClientTest`, `FederationServiceTest`)
+
+### Task 2: Peer status in the table (board SkInstellingenUitwisseling)
+- **spec_ref**: openspec/changes/federation-pull-fetches-the-peer/specs/federated-catalog-sync/spec.md#requirement-mirrored-entries-show-where-they-came-from-req-fed-011
+- **files**: `lib/Service/Federation/FederationService.php`, `src/views/settings/sections/FederationSettings.vue`, `tests/vitest/federationSettingsPeers.spec.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN `getStatus()` WHEN called THEN each peer carries name, lastPulledAt, objectCount and lastError
+ - GIVEN the peers table WHEN it renders THEN it shows catalogue, address, last pulled, objects and an error state, with Pull now
+- [ ] Implement
+- [ ] Test (vitest `federationSettingsPeers.spec.js`)
+
+### Task 3: Provenance on mirrored entries
+- **spec_ref**: openspec/changes/federation-pull-fetches-the-peer/specs/federated-catalog-sync/spec.md#requirement-mirrored-entries-show-where-they-came-from-req-fed-011
+- **files**: `src/components/federation/ProvenanceNotice.vue`, `src/manifest.json`, `src/customComponents.js`, `tests/vitest/provenanceNotice.spec.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN an object with `_source.instance` WHEN its detail page renders THEN "From: " links to the original and edit and delete are hidden
+ - GIVEN a list with mirrors WHEN it renders THEN mirrored rows carry a source badge
+- [ ] Implement
+- [ ] Test (vitest `provenanceNotice.spec.js`)
diff --git a/openspec/changes/gemma-plate-views-peers-and-definitions/proposal.md b/openspec/changes/gemma-plate-views-peers-and-definitions/proposal.md
new file mode 100644
index 000000000..38540ff43
--- /dev/null
+++ b/openspec/changes/gemma-plate-views-peers-and-definitions/proposal.md
@@ -0,0 +1,31 @@
+---
+kind: code
+depends_on:
+ - architecture-reference-component-coverage
+---
+
+# The GEMMA plate picks a view, shows what comparable organisations use, and explains GEMMA terms in place
+
+## Summary
+
+The GEMMA plate page (`src/views/organisaties/GemmaPlate.vue`, from `architecture-reference-component-coverage`) shows one organisation's applications per reference component with gaps and overlap, and shows the GEMMA definition of the component a user selects. Board SkGemmaPlaat draws three things the page does not do yet: choose which GEMMA view (plate) to look at, a layer and table "Wat vergelijkbare gemeenten gebruiken", and the definition of a GEMMA term everywhere it appears, including the reference component facet on the Applications page (board SkApplicaties: "Zaaksysteem, uit GEMMA", the definition, "Meer in GEMMA Online"). Three rows say built while their own evidence says the visible half is missing. The board is the canon (decision 130), so this change writes their requirements and the build tasks, and the rows move to building.
+
+## Rows
+
+- `arch-gemma-views` "Open a GEMMA architecture view with your own applications drawn inside it." Built today: the view API (`GET /api/views`, `/api/views/{viewId}`, `lib/Service/ViewService.php` enrichment) and the org export into Archi. Missing: a view picker on the plate, so the plate draws the reference components of the chosen GEMMA view in its groups; `src/store/modules/view.js` (`useViewStore`) is still imported by nothing.
+- `mkt-compare-peers` "See which software comparable organisations use for the same reference component." Built today: the shared-usage enrichment for participants only (`ViewService.php:1206-1265`). Missing: counts of other organisations' usages per reference component, and the page that renders them. The usage read rules limit a usage manager to its own organisation, so the counts are aggregated server side and never name an organisation.
+- `arch-definitions` "Read the definition of a GEMMA term in place while working in the catalogue." Built today: a standard's page shows its definition, and the plate's side panel shows `element.documentation` for the selected component. Missing: the definition in the reference component facet on the Applications and Services pages.
+
+## Decisions taken in this change (to confirm in review)
+
+- "Comparable" means the same organisation type (`organization.type`, for example municipality) and the same inhabitants band. The organisation schema has no inhabitants field, so this change adds an optional `inhabitants` integer. Bands: under 30,000; 30,000 to 60,000; 60,000 to 100,000; 100,000 to 250,000; above 250,000. Without a value, comparable falls back to the same type.
+- A count below 3 organisations is shown as "fewer than 3", so a single municipality's choice cannot be read off the table.
+
+## Out of scope
+
+- The "Gedeeld met partners" layer (`architecture-shared-usage-on-views`) and the GGM objects layer (`architecture-data-model-and-ggm`) on the same board.
+- Drawn views and their editor (`architecture-views-editor`).
+
+## Screens
+
+See `screens.md`: SkGemmaPlaat, SkApplicaties, SkStandaarden.
diff --git a/openspec/changes/gemma-plate-views-peers-and-definitions/screens.md b/openspec/changes/gemma-plate-views-peers-and-definitions/screens.md
new file mode 100644
index 000000000..ce532a37a
--- /dev/null
+++ b/openspec/changes/gemma-plate-views-peers-and-definitions/screens.md
@@ -0,0 +1,5 @@
+# Screens
+
+- SkGemmaPlaat: the view picker, the layer and table "Wat vergelijkbare gemeenten gebruiken", and the definition in the side panel https://identity.conduction.nl/screens/board?id=stackiq/SkGemmaPlaat
+- SkApplicaties: the reference component facet with "Zaaksysteem, uit GEMMA", its definition and "Meer in GEMMA Online" https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
+- SkStandaarden: the definition on a standard https://identity.conduction.nl/screens/board?id=stackiq/SkStandaarden
diff --git a/openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md b/openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md
new file mode 100644
index 000000000..41d43c98a
--- /dev/null
+++ b/openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md
@@ -0,0 +1,54 @@
+# gemma-plate Specification (delta)
+
+## Purpose
+
+The GEMMA plate page: one GEMMA view with the organisation's applications per reference component, what comparable organisations use, and GEMMA definitions in place. Covers rows arch-gemma-views, mkt-compare-peers and arch-definitions.
+
+## ADDED Requirements
+
+### Requirement: The plate draws the GEMMA view the user picks (REQ-GPL-001)
+
+The GEMMA plate page SHALL offer a picker of the imported GEMMA views of the application architecture, showing the GEMMA release. For the chosen view it SHALL draw the view's reference components in the view's groups (for example Front-office, Mid-office, Back-office en basisregistraties), each with the organisation's applications for it, read through `GET /api/views/{viewId}` with `include_gebruik`. The chosen view SHALL be kept in the URL.
+
+#### Scenario: A user switches plate
+- GIVEN two imported GEMMA views
+- WHEN the user picks "GEMMA Applicatiearchitectuur, gemeentebreed"
+- THEN the plate shows that view's groups and reference components with the organisation's applications, and the URL names the view
+
+#### Scenario: No GEMMA model
+- GIVEN no GEMMA views are imported
+- WHEN the user opens the plate
+- THEN the page says the GEMMA model is needed and the picker is empty
+
+### Requirement: The plate shows what comparable organisations use (REQ-GPL-002)
+
+The plate SHALL offer a layer "What comparable organisations use" and a table with, per reference component, the organisation's own applications and the applications comparable organisations use with their counts. Comparable SHALL mean the same organisation type and the same inhabitants band; without an inhabitants value, the same type. The counts SHALL be computed server side over all usages, SHALL never name another organisation, and a count below 3 SHALL be shown as "fewer than 3".
+
+#### Scenario: A municipality compares its case handling
+- GIVEN Zuiddrecht (45,000 inhabitants) uses Mozard for the Zaakafhandelcomponent, and 21 municipalities between 30,000 and 60,000 inhabitants use OpenZaak-UI for it
+- WHEN a user of Zuiddrecht switches on the layer
+- THEN the table row Zaakafhandelcomponent shows Mozard as own and "OpenZaak-UI 21" under comparable organisations
+
+#### Scenario: A gap that peers fill
+- GIVEN Zuiddrecht has no application for the Kennisbankcomponent and 12 comparable municipalities use Djuma Kennisbank
+- WHEN the user selects the Kennisbankcomponent on the plate
+- THEN the side panel says it is not covered and lists "Djuma Kennisbank (12)"
+
+#### Scenario: A rare choice stays anonymous
+- GIVEN only one comparable municipality uses a given application for a component
+- WHEN the table renders
+- THEN that application shows "fewer than 3" and no organisation is named
+
+### Requirement: GEMMA terms show their definition where they appear (REQ-GPL-003)
+
+Where a GEMMA reference component or standard is named as a choice, its GEMMA definition (`element.documentation`) SHALL be readable in place: in the side panel of the plate for the selected component, in the reference component facet of the Applications and Services pages (name, "from GEMMA", the definition and a link "More in GEMMA Online" to `element.gemmaUrl`), and on a standard's page.
+
+#### Scenario: A user reads a definition in the facet
+- GIVEN the Applications page with the reference component facet open
+- WHEN the user opens the information button next to "Zaaksysteem"
+- THEN a popover shows "Zaaksysteem, from GEMMA", its definition and a link to GEMMA Online
+
+#### Scenario: A component without a definition
+- GIVEN a reference component with no documentation
+- WHEN its popover opens
+- THEN it shows the name and the GEMMA Online link only
diff --git a/openspec/changes/gemma-plate-views-peers-and-definitions/tasks.md b/openspec/changes/gemma-plate-views-peers-and-definitions/tasks.md
new file mode 100644
index 000000000..b130bf2bb
--- /dev/null
+++ b/openspec/changes/gemma-plate-views-peers-and-definitions/tasks.md
@@ -0,0 +1,59 @@
+# Tasks: gemma-plate-views-peers-and-definitions
+
+## Implementation tasks
+
+### Task 1: View picker on the plate
+- **spec_ref**: openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md#requirement-the-plate-draws-the-gemma-view-the-user-picks-req-gpl-001
+- **files**: `src/views/organisaties/GemmaPlate.vue`, `src/store/modules/view.js`, `src/utils/referenceComponentCoverage.js`, `tests/vitest/gemmaPlateViews.spec.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN imported GEMMA views WHEN the plate loads THEN a picker lists them with the GEMMA release, and `useViewStore` fetches the chosen view with `include_gebruik`
+ - GIVEN a chosen view WHEN it renders THEN reference components are grouped by the view's groups, keeping the gap, covered and overlap states
+ - GIVEN `?view=` in the URL WHEN the page opens THEN that view is chosen
+- [ ] Implement
+- [ ] Test (vitest `gemmaPlateViews.spec.js`)
+
+### Task 2: Inhabitants on the organisation
+- **spec_ref**: openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md#requirement-the-plate-shows-what-comparable-organisations-use-req-gpl-002
+- **files**: `lib/Settings/softwarecatalogus_register.json`, `src/manifest.json`, `tests/Unit/Settings/OrganizationInhabitantsTest.php`
+- **acceptance_criteria**:
+ - GIVEN the organization schema WHEN read THEN it has an optional integer `inhabitants` (minimum 0) shown on the organisation page and form
+- [ ] Implement
+- [ ] Test (PHPUnit `OrganizationInhabitantsTest`)
+
+### Task 3: Peer counts per reference component
+- **spec_ref**: openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md#requirement-the-plate-shows-what-comparable-organisations-use-req-gpl-002
+- **files**: `lib/Service/PeerUsageService.php`, `lib/Controller/ReferenceComponentCoverageController.php`, `appinfo/routes.php`, `tests/Unit/Service/PeerUsageServiceTest.php`, `tests/Unit/Controller/ReferenceComponentCoverageControllerTest.php`
+- **acceptance_criteria**:
+ - GIVEN `GET /api/reference-component-coverage/peers?organisation=` WHEN called by a member of that organisation THEN it returns, per reference component, the applications comparable organisations use with a count, computed over all usages with the service identity and bounded like the coverage endpoint
+ - GIVEN a count below 3 WHEN returned THEN it is `null` with `fewerThan: 3`, and no organisation id or name appears anywhere in the response
+ - GIVEN a caller who is not a member of the organisation WHEN calling THEN it is refused (the check in `OrganisationReportAccess` runs before the query)
+ - GIVEN the organisation has no inhabitants value WHEN computing THEN comparable is the same `type`
+- [ ] Implement
+- [ ] Test (PHPUnit `PeerUsageServiceTest`, `ReferenceComponentCoverageControllerTest`)
+
+### Task 4: The peers layer, table and side panel line
+- **spec_ref**: openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md#requirement-the-plate-shows-what-comparable-organisations-use-req-gpl-002
+- **files**: `src/views/organisaties/GemmaPlate.vue`, `src/components/gemma/PeerUsageTable.vue`, `tests/vitest/peerUsageTable.spec.js`, `tests/e2e/spec-coverage/gemma-plate-peers.spec.ts`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN the Layers control WHEN "What comparable organisations use" is switched on THEN the table shows per component the own applications and the peer applications with counts, under a heading naming the band
+ - GIVEN a selected component WHEN the side panel renders THEN it lists the peer applications with counts
+- [ ] Implement
+- [ ] Test (vitest `peerUsageTable.spec.js`, Playwright `gemma-plate-peers.spec.ts`)
+
+### Task 5: Definitions in the reference component facet
+- **spec_ref**: openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md#requirement-gemma-terms-show-their-definition-where-they-appear-req-gpl-003
+- **files**: `src/views/FacetedCatalogIndexView.vue`, `src/components/gemma/GemmaDefinitionPopover.vue`, `lib/Service/FacetService.php`, `tests/vitest/gemmaDefinitionPopover.spec.js`, `tests/Unit/Service/FacetServiceDefinitionTest.php`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN the reference component facet WHEN it renders THEN each value has an information button whose popover shows the name, "from GEMMA", `element.documentation` and a "More in GEMMA Online" link to `element.gemmaUrl`
+ - GIVEN the facet endpoint WHEN it returns reference component buckets THEN each bucket carries `documentation` and `gemmaUrl`
+ - GIVEN a component without documentation WHEN its popover opens THEN only the name and the link show
+- [ ] Implement
+- [ ] Test (vitest `gemmaDefinitionPopover.spec.js`, PHPUnit `FacetServiceDefinitionTest`)
+
+### Task 6: Documentation and matrix
+- **spec_ref**: openspec/changes/gemma-plate-views-peers-and-definitions/specs/gemma-plate/spec.md
+- **files**: `docs/features/reference-component-coverage.md`, `openspec/parity/capabilities.json`
+- **acceptance_criteria**:
+ - GIVEN the feature doc WHEN read THEN it explains the view picker, the comparable organisations layer and the anonymity rule
+ - GIVEN the tasks are done WHEN the change is archived THEN the three rows move back to built with new evidence
+- [ ] Implement
diff --git a/openspec/changes/i18n-wrap-hardcoded-object-modal-strings/screens.md b/openspec/changes/i18n-wrap-hardcoded-object-modal-strings/screens.md
new file mode 100644
index 000000000..61cb92984
--- /dev/null
+++ b/openspec/changes/i18n-wrap-hardcoded-object-modal-strings/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: Translates existing strings; no layout changes.
diff --git a/openspec/changes/insight-assistant-answers-and-action-trail/screens.md b/openspec/changes/insight-assistant-answers-and-action-trail/screens.md
new file mode 100644
index 000000000..2b96ccc27
--- /dev/null
+++ b/openspec/changes/insight-assistant-answers-and-action-trail/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkDashboard https://identity.conduction.nl/screens/board?id=stackiq/SkDashboard
diff --git a/openspec/changes/insight-exports-and-custom-reports/screens.md b/openspec/changes/insight-exports-and-custom-reports/screens.md
new file mode 100644
index 000000000..d680fbeba
--- /dev/null
+++ b/openspec/changes/insight-exports-and-custom-reports/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkApplicaties https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
+- SkRapportages https://identity.conduction.nl/screens/board?id=stackiq/SkRapportages
diff --git a/openspec/changes/insight-knowledge-base/screens.md b/openspec/changes/insight-knowledge-base/screens.md
new file mode 100644
index 000000000..485a297d1
--- /dev/null
+++ b/openspec/changes/insight-knowledge-base/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkApplicatie https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
diff --git a/openspec/changes/insight-supplier-facet/screens.md b/openspec/changes/insight-supplier-facet/screens.md
new file mode 100644
index 000000000..8db7a228a
--- /dev/null
+++ b/openspec/changes/insight-supplier-facet/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkApplicaties https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
diff --git a/openspec/changes/landscape-application-components/screens.md b/openspec/changes/landscape-application-components/screens.md
new file mode 100644
index 000000000..9743e6d06
--- /dev/null
+++ b/openspec/changes/landscape-application-components/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkApplicatie https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
+- SkSuiteWizard https://identity.conduction.nl/screens/board?id=stackiq/SkSuiteWizard
diff --git a/openspec/changes/landscape-change-entry-type/screens.md b/openspec/changes/landscape-change-entry-type/screens.md
new file mode 100644
index 000000000..8db7a228a
--- /dev/null
+++ b/openspec/changes/landscape-change-entry-type/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkApplicaties https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
diff --git a/openspec/changes/landscape-completeness-score/design.md b/openspec/changes/landscape-completeness-score/design.md
index 37f9ded03..b17bb0d96 100644
--- a/openspec/changes/landscape-completeness-score/design.md
+++ b/openspec/changes/landscape-completeness-score/design.md
@@ -42,9 +42,22 @@ Rejected: a stackiq scoring service. OpenRegister already scores on save and ser
A header action on `ModuleDetail` and on the usage detail page that saves `lastConfirmedAt = now` and nothing else. The save triggers the rescore, so the freshness part goes back to full. The action shows for users who may update the entry.
-## D4. The report
-
-`Reports` (`src/manifest.json:1021`) gets a second card, "Data quality", routing to a new page `DataQualityReport` (`/data-quality`), a custom view `src/views/DataQualityReportView.vue` that calls the two OpenRegister endpoints per schema (`module`, `usage`): the score spread (good, fair, poor counts, from `stats`) and the twenty lowest entries (from the listing), each row opening its page.
+## D4. The page
+
+The board (SkDatakwaliteit) makes the report a page of its own under Reports &
+Compliance, per organisation, rather than a card with an applications-and-usages
+spread. `GET /api/data-quality?organisation=` (`DataQualityReportController`,
+`DataQualityReportService`) reads the usage schema's declared rules and one
+bounded, organisation-scoped query of usages (the portfolio report's bound), and
+`DataQualityDerivation` evaluates them the way OpenRegister's QualityScorer
+scores: the register score and average completeness out of 100, entries not
+confirmed within the freshness half-life, how many entries meet each rule, and
+the twenty least complete entries with what is missing. The organisation check
+is `OrganisationReportAccess`, shared with the portfolio report. The page
+(`src/views/organisaties/DataQualityReport.vue`) renders that response with
+CnKpiGrid, CnProgressBar and CnDataTable. The board's duplicates and owner
+survey sections wait for OpenRegister's duplicate listing on usage and for
+`landscape-owner-attestation`.
## D5. Existing rows
diff --git a/openspec/changes/landscape-completeness-score/proposal.md b/openspec/changes/landscape-completeness-score/proposal.md
index 4bf27d6a0..dfdcb8941 100644
--- a/openspec/changes/landscape-completeness-score/proposal.md
+++ b/openspec/changes/landscape-completeness-score/proposal.md
@@ -31,12 +31,13 @@ No tender, feature request or roadmap row names these rows.
2. Hidden `qualityScore` and `qualityStatus` fields on both schemas, so OpenRegister's statistics and listing work.
3. A Data quality column on the Applications list and on Applications in use, and a score tile on the application page.
4. A "Confirm this entry is current" action on the application and usage pages that sets `lastConfirmedAt`.
-5. A Data quality report, a card on the Reports page, with the score spread per type and the twenty weakest entries.
+5. A Data quality page (board SkDatakwaliteit) per organisation: the register score, average completeness, entries not confirmed, the score per declared rule and the entries with the lowest completeness first, with what is missing.
## Out of scope
- Asking owners to confirm their entries by survey: `landscape-owner-attestation`, which sets the same `lastConfirmedAt`.
- Scores for contracts, connections and compliance claims; the same annotation can be added per schema later.
+- Duplicates from several sources and the owner survey on the board: OpenRegister's duplicate listing and `landscape-owner-attestation` fill those sections later.
- An admin screen for the rule set. The rules live in the register fragment and are edited like code, or in OpenRegister's schema editor.
## Risks
diff --git a/openspec/changes/landscape-completeness-score/screens.md b/openspec/changes/landscape-completeness-score/screens.md
new file mode 100644
index 000000000..35e97bb91
--- /dev/null
+++ b/openspec/changes/landscape-completeness-score/screens.md
@@ -0,0 +1,5 @@
+# Screens
+
+- SkApplicaties https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
+- SkComplianceMatrix https://identity.conduction.nl/screens/board?id=stackiq/SkComplianceMatrix
+- SkDatakwaliteit https://identity.conduction.nl/screens/board?id=stackiq/SkDatakwaliteit
diff --git a/openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md b/openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md
index 66c5beca2..056cea1de 100644
--- a/openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md
+++ b/openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md
@@ -41,14 +41,22 @@ The application page and the usage page SHALL offer "Confirm this entry is curre
- **THEN** the page shows today as last confirmed
- **AND** the data quality score is higher than before
-### Requirement: REQ-CDQ-003 A data quality report shows the spread and the weakest entries
+### Requirement: REQ-CDQ-003 A Data quality page shows how complete and current an organisation's register is
-The Reports page SHALL offer a Data quality report that shows, for applications and for usages, how many entries are good, fair and poor, and lists the twenty weakest entries first, each opening its page.
+The app SHALL offer a Data quality page (board SkDatakwaliteit) for one organisation's applications in use. It SHALL show the register score (the average score, out of 100, against the declared rules), the average completeness (the share of the required rules met), and how many entries were never confirmed or were confirmed longer ago than the freshness rule allows. It SHALL list every declared rule with how many entries meet it, and the entries with the lowest completeness first, each with what is missing and when it was last confirmed. The figures SHALL be computed on the server from the rules the usage schema declares, over a bounded, organisation-scoped set of usages, and a user SHALL only see an organisation they may see the portfolio report of.
#### Scenario: An information manager finds what to fix first
@e2e tests/e2e/workflows/data-quality.spec.ts
-- **GIVEN** the catalogue has good, fair and poor applications
-- **WHEN** the information manager opens Reports, then Data quality
-- **THEN** the page shows the three counts for applications
-- **AND** the first rows of the weakest list are poor entries
+- **GIVEN** Gemeente Zuiddrecht has applications in use, some without a business owner and some never confirmed
+- **WHEN** the information manager opens Data quality and picks Gemeente Zuiddrecht
+- **THEN** the page shows the register score, the average completeness and the number not confirmed
+- **AND** the rule "business owner" shows how many entries meet it out of the total
+- **AND** the first row of the completeness list is the entry with the most missing, naming what is missing
+
+#### Scenario: A user cannot read another organisation's data quality
+@e2e exclude Needs a second, non-admin user in another organisation; tests/Unit/Controller/DataQualityReportControllerTest.php asserts the 403 and that the report service is never called.
+
+- **GIVEN** a user whose organisation is Gemeente Alblasserdam and who is not an administrator or civil servant
+- **WHEN** they ask for the data quality of Gemeente Zuiddrecht
+- **THEN** the request is refused before any usage is read
diff --git a/openspec/changes/landscape-completeness-score/tasks.md b/openspec/changes/landscape-completeness-score/tasks.md
index 6aa5cc815..ac1a2632a 100644
--- a/openspec/changes/landscape-completeness-score/tasks.md
+++ b/openspec/changes/landscape-completeness-score/tasks.md
@@ -4,12 +4,12 @@
### Task 1: Rule set and fields
- **spec_ref**: openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md#requirement-req-cdq-001-every-application-and-usage-carries-a-data-quality-score-from-a-declared-rule-set
-- **files**: `lib/Settings/register.d/data-quality.json`, `lib/Settings/stackiq_mock_register.json`
+- **files**: `lib/Settings/register.d/data-quality.json` (the module schema already declared its rules; it gets the score fields)
- **acceptance_criteria**:
- GIVEN an application with every weighted field filled and confirmed today WHEN it is saved THEN its status is good
- GIVEN an application without reference components and never confirmed WHEN it is saved THEN its status is poor or fair
-- [ ] Implement
-- [ ] Test (PHPUnit `tests/Unit/Settings/DataQualityFragmentTest.php`: annotation passes OpenRegister's shape rules, every rule field exists on its schema)
+- [x] Implement
+- [x] Test (PHPUnit `tests/Unit/Settings/DataQualityFragmentTest.php`: annotation passes OpenRegister's shape rules, every rule field exists on its schema)
### Task 2: Score on lists and the application page, and the confirm action
- **spec_ref**: openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md#requirement-req-cdq-002-a-user-confirms-an-entry-is-current-and-its-freshness-resets
@@ -20,13 +20,14 @@
- [ ] Implement
- [ ] Test (Playwright `tests/e2e/workflows/data-quality.spec.ts`)
-### Task 3: Data quality report
-- **spec_ref**: openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md#requirement-req-cdq-003-a-data-quality-report-shows-the-spread-and-the-weakest-entries
-- **files**: `src/views/DataQualityReportView.vue`, `src/customComponents.js`, `src/manifest.json` (Reports card, DataQualityReport page)
+### Task 3: Data quality page (board SkDatakwaliteit)
+- **spec_ref**: openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md#requirement-req-cdq-003-a-data-quality-page-shows-how-complete-and-current-an-organisation-s-register-is
+- **files**: `lib/Service/DataQualityDerivation.php`, `lib/Service/DataQualityReportService.php`, `lib/Service/OrganisationReportAccess.php`, `lib/Controller/DataQualityReportController.php`, `lib/Controller/PortfolioReportController.php`, `appinfo/routes.php`, `src/views/organisaties/DataQualityReport.vue`, `src/manifest.d/data-quality.json`, `src/customComponents.js`
- **acceptance_criteria**:
- - GIVEN applications with mixed scores WHEN the information manager opens Reports, Data quality THEN the page shows the good, fair and poor counts and the weakest entries first
-- [ ] Implement
-- [ ] Test (vitest `tests/vitest/dataQualityReport.spec.js` for the view model; Playwright case in `tests/e2e/workflows/data-quality.spec.ts`)
+ - GIVEN usages with mixed scores WHEN the information manager opens Data quality THEN the page shows the register score, average completeness, not-confirmed count, the per-rule counts and the lowest completeness first
+ - GIVEN a user of another organisation WHEN they request the report THEN it is refused before any query
+- [x] Implement
+- [ ] Test (PHPUnit `tests/Unit/Service/DataQualityDerivationTest.php`, `tests/Unit/Service/OrganisationReportAccessTest.php`; Playwright case in `tests/e2e/workflows/data-quality.spec.ts`)
### Task 4: Rescore existing rows
- **spec_ref**: openspec/changes/landscape-completeness-score/specs/catalogue-data-quality/spec.md#requirement-req-cdq-001-every-application-and-usage-carries-a-data-quality-score-from-a-declared-rule-set
diff --git a/openspec/changes/landscape-dependent-field-options/screens.md b/openspec/changes/landscape-dependent-field-options/screens.md
new file mode 100644
index 000000000..485a297d1
--- /dev/null
+++ b/openspec/changes/landscape-dependent-field-options/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkApplicatie https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
diff --git a/openspec/changes/landscape-migrate-from-previous-catalogue/proposal.md b/openspec/changes/landscape-migrate-from-previous-catalogue/proposal.md
new file mode 100644
index 000000000..d190ada83
--- /dev/null
+++ b/openspec/changes/landscape-migrate-from-previous-catalogue/proposal.md
@@ -0,0 +1,31 @@
+---
+kind: code
+depends_on: []
+---
+
+# Bring over what an organisation registered in the previous Softwarecatalogus
+
+## Summary
+
+Municipalities have registered their applications, versions, suppliers and connections in the VNG GEMMA Softwarecatalogus for years. Moving to stackiq should not mean typing that again. Board SkApplicaties draws the entry point: "Overnemen uit de vorige Softwarecatalogus" in the actions menu of the Applications page. Nothing in stackiq does it today: row `land-migrate-legacy` has state none, no spec and no change.
+
+stackiq already has a profile-driven workbook import (`lib/Service/Cmdb/`, `POST /api/cmdb-import`, profile `lib/Settings/cmdb-import/topdesk-profile.json` with packs per target), used for the TOPdesk CMDB export. This change adds a second profile for the previous catalogue's export, a preview step before anything is written, and a result step. It reuses the reader, the bounds, the owner identity and the report.
+
+## Rows
+
+- `land-migrate-legacy` "Bring over what was registered in the previous catalogue, so nobody types it again."
+
+## Decisions taken in this change (to confirm in review)
+
+- Source: the organisation's export from the previous Softwarecatalogus as a spreadsheet (xlsx or csv: applications in use with supplier, product, version, reference components, status and connections). An API pull from softwarecatalogus.nl is out of scope until VNG offers an export API with organisation authorisation.
+- Matching: a supplier matches an existing organisation on KvK number, else on exact name; a product matches an existing module of that supplier on name; a version on version number. Anything unmatched is created as a concept, never merged by fuzzy guess.
+- Usages are created for the active organisation only, and a second run updates instead of duplicating (keyed on the previous catalogue's id, stored as `legacyId`).
+
+## Out of scope
+
+- The external VNG frontend repository (`Softwarecatalogus/`), which this team does not commit to.
+- Other organisations' registrations: each organisation brings over its own.
+
+## Screens
+
+See `screens.md`: SkApplicaties. The board draws the entry only; a board lane draws the mapping and result steps (matrix gap list (b)).
diff --git a/openspec/changes/landscape-migrate-from-previous-catalogue/screens.md b/openspec/changes/landscape-migrate-from-previous-catalogue/screens.md
new file mode 100644
index 000000000..a91be49ab
--- /dev/null
+++ b/openspec/changes/landscape-migrate-from-previous-catalogue/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkApplicaties: the actions menu item "Overnemen uit de vorige Softwarecatalogus" https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
+- pending: stackiq/SkApplicaties import dialog with the mapping preview and the result (board lane, matrix gap list (b))
diff --git a/openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md b/openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md
new file mode 100644
index 000000000..467fc2a63
--- /dev/null
+++ b/openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md
@@ -0,0 +1,49 @@
+# legacy-catalogue-migration Specification (delta)
+
+## Purpose
+
+How an organisation brings its registrations from the previous VNG Softwarecatalogus into stackiq. Covers row land-migrate-legacy.
+
+## ADDED Requirements
+
+### Requirement: An organisation imports its export from the previous catalogue (REQ-LCM-001)
+
+The Applications page actions menu SHALL offer "Bring over from the previous Softwarecatalogus" to users who may register usages for the active organisation. It SHALL accept the previous catalogue's export as xlsx or csv within the import limits, read it with a dedicated import profile, and SHALL NOT write anything before the user confirms the preview.
+
+#### Scenario: Opening the import
+- GIVEN a usage manager of Gemeente Zuiddrecht on the Applications page
+- WHEN they choose "Bring over from the previous Softwarecatalogus" and pick the export file
+- THEN the preview step opens and no object has been created
+
+#### Scenario: A file that is not an export
+- GIVEN a spreadsheet without the expected columns
+- WHEN it is uploaded
+- THEN the import says which required columns are missing and stops
+
+### Requirement: The preview shows the mapping before anything is written (REQ-LCM-002)
+
+The preview SHALL show, per row of the export, the supplier, product, version and usage it maps to and whether each will be matched to an existing object or created as a concept. Matching SHALL use the KvK number, then the exact name, for suppliers; the supplier and name for products; the version number for versions. The user SHALL be able to leave rows out before confirming.
+
+#### Scenario: A known supplier and product
+- GIVEN the export lists Centric, Key2Financiën 2026.1, and stackiq holds Centric and Key2Financiën
+- WHEN the preview renders
+- THEN the row shows "matched" for supplier and product and "create" for the usage
+
+#### Scenario: An unknown product
+- GIVEN the export lists a product stackiq does not hold
+- WHEN the preview renders
+- THEN the row shows the product will be created as a concept
+
+### Requirement: The result says what was created, updated and skipped (REQ-LCM-003)
+
+After confirming, the import SHALL create or update the usages of the active organisation with the matched or created products and versions, link their reference components and connections where they resolve, and store the previous catalogue's id as `legacyId`. A second run of the same export SHALL update, not duplicate. The result step SHALL count created, updated and skipped rows and list each skipped row with its reason.
+
+#### Scenario: The first run
+- GIVEN a preview with 120 rows, 4 left out
+- WHEN the user confirms
+- THEN 116 usages exist for the organisation and the result reads created 116, updated 0, skipped 4
+
+#### Scenario: Running it again
+- GIVEN the same export a week later with one changed version
+- WHEN the import runs
+- THEN no usage is duplicated and the result reads updated 1
diff --git a/openspec/changes/landscape-migrate-from-previous-catalogue/tasks.md b/openspec/changes/landscape-migrate-from-previous-catalogue/tasks.md
new file mode 100644
index 000000000..3ced65387
--- /dev/null
+++ b/openspec/changes/landscape-migrate-from-previous-catalogue/tasks.md
@@ -0,0 +1,47 @@
+# Tasks: landscape-migrate-from-previous-catalogue
+
+## Implementation tasks
+
+### Task 1: Import profile for the previous catalogue's export
+- **spec_ref**: openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md#requirement-an-organisation-imports-its-export-from-the-previous-catalogue-req-lcm-001
+- **files**: `lib/Settings/cmdb-import/vng-swc-profile.json`, `lib/Settings/cmdb-import/vng-swc-supplier.json`, `lib/Settings/cmdb-import/vng-swc-module.json`, `lib/Settings/cmdb-import/vng-swc-version.json`, `lib/Settings/cmdb-import/vng-swc-usage.json`, `lib/Service/Cmdb/CmdbImportProfile.php`, `tests/Unit/Service/Cmdb/VngSwcProfileTest.php`, `tests/fixtures/vng-swc-export.xlsx`
+- **acceptance_criteria**:
+ - GIVEN the profile WHEN loaded THEN it names the export's sheet and columns, the key column (previous catalogue id), the required columns and the limits, and one pack per target
+ - GIVEN the fixture WHEN read THEN rows normalise to supplier, module, version and usage records; a file without the required columns is refused with their names
+- [ ] Implement
+- [ ] Test (PHPUnit `VngSwcProfileTest`)
+
+### Task 2: Matching and the preview (dry run)
+- **spec_ref**: openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md#requirement-the-preview-shows-the-mapping-before-anything-is-written-req-lcm-002
+- **files**: `lib/Service/LegacyCatalogueMigrationService.php`, `lib/Controller/CmdbImportController.php`, `appinfo/routes.php`, `tests/Unit/Service/LegacyCatalogueMigrationServiceTest.php`
+- **acceptance_criteria**:
+ - GIVEN `POST /api/legacy-import/preview` with a file WHEN called by a member of the active organisation who may create usages THEN it returns per row the match or create decision for supplier (KvK, then exact name), module (supplier and name) and version (number), and writes nothing
+ - GIVEN a caller without that right WHEN calling THEN 403 before the file is read
+- [ ] Implement
+- [ ] Test (PHPUnit `LegacyCatalogueMigrationServiceTest`)
+
+### Task 3: Apply and the result
+- **spec_ref**: openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md#requirement-the-result-says-what-was-created-updated-and-skipped-req-lcm-003
+- **files**: `lib/Service/LegacyCatalogueMigrationService.php`, `lib/Settings/softwarecatalogus_register.json`, `tests/Unit/Service/LegacyCatalogueMigrationApplyTest.php`
+- **acceptance_criteria**:
+ - GIVEN the usage, module and organization schemas WHEN read THEN each has an optional `legacyId`
+ - GIVEN `POST /api/legacy-import/apply` with the preview token and the rows left out WHEN called THEN usages are created or updated for the active organisation keyed on `legacyId`, unmatched products and suppliers are created with status concept, and the response counts created, updated and skipped with reasons
+ - GIVEN the same export twice WHEN applied THEN the second run creates no duplicates
+- [ ] Implement
+- [ ] Test (PHPUnit `LegacyCatalogueMigrationApplyTest`)
+
+### Task 4: The dialog (board SkApplicaties)
+- **spec_ref**: openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md#requirement-an-organisation-imports-its-export-from-the-previous-catalogue-req-lcm-001
+- **files**: `src/modals/import/LegacyCatalogueImportModal.vue`, `src/views/FacetedCatalogIndexView.vue`, `tests/vitest/legacyCatalogueImportModal.spec.js`, `tests/e2e/spec-coverage/legacy-catalogue-import.spec.ts`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN the Applications actions menu WHEN opened by a user who may create usages THEN it lists "Bring over from the previous Softwarecatalogus"
+ - GIVEN the modal WHEN used THEN it has three steps: file, preview (rows with match or create, a checkbox per row) and result (created, updated, skipped with reasons)
+- [ ] Implement
+- [ ] Test (vitest `legacyCatalogueImportModal.spec.js`, Playwright `legacy-catalogue-import.spec.ts`)
+
+### Task 5: Documentation
+- **spec_ref**: openspec/changes/landscape-migrate-from-previous-catalogue/specs/legacy-catalogue-migration/spec.md
+- **files**: `docs/features/legacy-catalogue-migration.md`
+- **acceptance_criteria**:
+ - GIVEN the doc WHEN read THEN it says how to get the export from the previous catalogue, how matching works and how to run it again
+- [ ] Implement
diff --git a/openspec/changes/landscape-move-between-organisations/screens.md b/openspec/changes/landscape-move-between-organisations/screens.md
new file mode 100644
index 000000000..8db7a228a
--- /dev/null
+++ b/openspec/changes/landscape-move-between-organisations/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkApplicaties https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
diff --git a/openspec/changes/landscape-owner-attestation/screens.md b/openspec/changes/landscape-owner-attestation/screens.md
new file mode 100644
index 000000000..053e8b869
--- /dev/null
+++ b/openspec/changes/landscape-owner-attestation/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkDatakwaliteit https://identity.conduction.nl/screens/board?id=stackiq/SkDatakwaliteit
diff --git a/openspec/changes/landscape-services-and-module-versions/proposal.md b/openspec/changes/landscape-services-and-module-versions/proposal.md
new file mode 100644
index 000000000..caa43d6d2
--- /dev/null
+++ b/openspec/changes/landscape-services-and-module-versions/proposal.md
@@ -0,0 +1,19 @@
+---
+kind: code
+depends_on: []
+---
+
+# Requirements for module versions and services, written after the fact
+
+## Summary
+
+Two built features have no requirement that says them. Module versions (a released version of an application, with the date it came into use) and services (what a supplier delivers on top of applications, such as hosting or support) are registered on their own pages, but their rows cite specs that cover something else: the generic manifest rules and the GEMMA facets. This change writes the two requirements so both rows can cite them, and adds the tests and `@spec` tags that keep them true. No new behaviour.
+
+## Rows
+
+- `land-module-versions` "Record the released versions of a module, with the date each came into use." Built: schema `moduleVersion` (`version`, `status`, `dateInDevelopment`, `dateInUse`, `dateEndSupport`, `dateWithdrawn`) in `lib/Settings/softwarecatalogus_register.json`, the Module versions index (`/moduleversies`) and detail page (`/moduleversies/:id`) in `src/manifest.json`. Board SkModuleversie.
+- `land-services` "Register a service a supplier delivers on top of applications, such as hosting or support." Built: schema `catalogService` (`name`, `provider`, `modules`, `type` from Functional management, Application management, Technical management, Implementation support, Training, Licence reseller) and the Services page (`/diensten`, the faceted index), plus the Services list on an organisation. Board SkApplicaties draws services as the Diensten tab with its count, which is what the Services page shows. The board draws no service detail page and no hosting service type, so the row evidence that names them as missing is not a gap against the canon (decision 130).
+
+## Screens
+
+See `screens.md`.
diff --git a/openspec/changes/landscape-services-and-module-versions/screens.md b/openspec/changes/landscape-services-and-module-versions/screens.md
new file mode 100644
index 000000000..5f643a68f
--- /dev/null
+++ b/openspec/changes/landscape-services-and-module-versions/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkModuleversie: a module version with release date, in-use date and support state https://identity.conduction.nl/screens/board?id=stackiq/SkModuleversie
+- SkApplicaties: the Diensten tab next to Applicaties https://identity.conduction.nl/screens/board?id=stackiq/SkApplicaties
diff --git a/openspec/changes/landscape-services-and-module-versions/specs/catalogue-services/spec.md b/openspec/changes/landscape-services-and-module-versions/specs/catalogue-services/spec.md
new file mode 100644
index 000000000..6c72d31a5
--- /dev/null
+++ b/openspec/changes/landscape-services-and-module-versions/specs/catalogue-services/spec.md
@@ -0,0 +1,26 @@
+# catalogue-services Specification (delta)
+
+## Purpose
+
+How stackiq registers the services a supplier delivers on top of applications. Covers row land-services.
+
+## ADDED Requirements
+
+### Requirement: A supplier's service is registered over one or more applications (REQ-CSV-001)
+
+stackiq SHALL record a service with a name, its provider organisation, one or more service types (Functional management, Application management, Technical management, Implementation support, Training, Licence reseller) and the applications it covers. Name, provider and type SHALL be required. The Services page SHALL list services with name, provider, type and publication date and filter them by the GEMMA facets of the applications they cover, and an organisation's page SHALL list the services it provides.
+
+#### Scenario: A supplier registers hosting and support
+- GIVEN a supplier with the application "Mozard"
+- WHEN the supplier registers the service "Beheer en hosting Mozard" with types Technical management and Application management, covering Mozard
+- THEN the Services page lists it with the supplier and both types
+
+#### Scenario: A service without a provider is refused
+- GIVEN the service form
+- WHEN a user saves a service without a provider
+- THEN the save is refused and the provider field is marked required
+
+#### Scenario: The organisation page lists its services
+- GIVEN the supplier provides two services
+- WHEN a user opens the supplier's organisation page
+- THEN the Services list shows both
diff --git a/openspec/changes/landscape-services-and-module-versions/specs/module-versions/spec.md b/openspec/changes/landscape-services-and-module-versions/specs/module-versions/spec.md
new file mode 100644
index 000000000..3d99d0fa4
--- /dev/null
+++ b/openspec/changes/landscape-services-and-module-versions/specs/module-versions/spec.md
@@ -0,0 +1,26 @@
+# module-versions Specification (delta)
+
+## Purpose
+
+How stackiq records the released versions of an application and where each version is in its lifecycle. Covers row land-module-versions.
+
+## ADDED Requirements
+
+### Requirement: A released version of an application is recorded with its lifecycle dates (REQ-MVR-001)
+
+stackiq SHALL record each released version of an application as a module version linked to its application, with a version number, a status and the dates it entered development, came into use, leaves support and was withdrawn. The Module versions page SHALL list the versions with application, version, in-development date, in-use date and status, and SHALL offer a quick filter for planned releases. A version SHALL open on its own detail page with its data, its application and usages, its compliance claims and its release files.
+
+#### Scenario: A supplier registers a new version
+- GIVEN an application "OpenRegister" offered by the supplier
+- WHEN the supplier adds version 1.5.0 with in-use date 6 October 2026
+- THEN the Module versions page lists 1.5.0 for OpenRegister with that date
+
+#### Scenario: Planned releases
+- GIVEN a version with status "in development"
+- WHEN a user picks the quick filter "Planned releases"
+- THEN only versions in development are listed
+
+#### Scenario: A version opens on its own page
+- GIVEN version 1.4.2 of OpenRegister
+- WHEN a user opens it from the list
+- THEN its detail page shows the version data, the application and its usages, the compliance claims and the release files
diff --git a/openspec/changes/landscape-services-and-module-versions/tasks.md b/openspec/changes/landscape-services-and-module-versions/tasks.md
new file mode 100644
index 000000000..103e602ca
--- /dev/null
+++ b/openspec/changes/landscape-services-and-module-versions/tasks.md
@@ -0,0 +1,28 @@
+# Tasks: landscape-services-and-module-versions
+
+## Implementation tasks
+
+### Task 1: Tag and test module versions
+- **spec_ref**: openspec/changes/landscape-services-and-module-versions/specs/module-versions/spec.md#requirement-a-released-version-of-an-application-is-recorded-with-its-lifecycle-dates-req-mvr-001
+- **files**: `src/manifest.json`, `tests/vitest/moduleVersionPages.spec.js`
+- **acceptance_criteria**:
+ - GIVEN the Moduleversies and ModuleversieDetail pages WHEN read THEN their `_note` cites REQ-MVR-001
+ - GIVEN the merged manifest WHEN the vitest reads Moduleversies THEN it lists version, module, dateInDevelopment, dateInUse and status and has the "Planned releases" quick filter on status "in development"
+- [ ] Implement
+- [ ] Test (vitest `moduleVersionPages.spec.js`)
+
+### Task 2: Tag and test services
+- **spec_ref**: openspec/changes/landscape-services-and-module-versions/specs/catalogue-services/spec.md#requirement-a-suppliers-service-is-registered-over-one-or-more-applications-req-csv-001
+- **files**: `src/manifest.json`, `tests/Unit/Settings/CatalogServiceSchemaTest.php`
+- **acceptance_criteria**:
+ - GIVEN the Diensten page and the org-diensten widget WHEN read THEN their `_note` cites REQ-CSV-001
+ - GIVEN `lib/Settings/softwarecatalogus_register.json` WHEN the test reads `catalogService` THEN name, provider and type are required and type holds the six service types
+- [ ] Implement
+- [ ] Test (PHPUnit `CatalogServiceSchemaTest`)
+
+### Task 3: Matrix
+- **spec_ref**: openspec/changes/landscape-services-and-module-versions/specs/module-versions/spec.md
+- **files**: `openspec/parity/capabilities.json`
+- **acceptance_criteria**:
+ - GIVEN the change is archived WHEN the matrix is read THEN both rows cite the merged specs
+- [ ] Implement
diff --git a/openspec/changes/mail-template-editor-saves/proposal.md b/openspec/changes/mail-template-editor-saves/proposal.md
new file mode 100644
index 000000000..d6815baf1
--- /dev/null
+++ b/openspec/changes/mail-template-editor-saves/proposal.md
@@ -0,0 +1,18 @@
+---
+kind: code
+depends_on: []
+---
+
+# The mail template editor saves, and the settings say when no mail goes out
+
+## Summary
+
+stackiq sends its registration, activation and account mails from templates. The backend can store an edited template (`POST /api/email/templates/{templateName}`, `SettingsController::updateEmailTemplate`, admin only). The admin editor's Save button does not call it: `saveTemplate()` in `src/views/settings/sections/EmailConfiguration.vue` logs "not yet implemented" and shows a success message anyway, so an administrator believes a change was saved that was not. The transport defaults to `null`, which sends nothing until configured, and the settings do not say so. Board SkBeheerToegang draws the mail templates as editable. The board is the canon (decision 130), so the row moves from built to building with this plan.
+
+## Rows
+
+- `org-activation-mail` "Send registration, activation and account mails from templates an administrator can edit."
+
+## Screens
+
+See `screens.md`: SkBeheerToegang.
diff --git a/openspec/changes/mail-template-editor-saves/screens.md b/openspec/changes/mail-template-editor-saves/screens.md
new file mode 100644
index 000000000..b73c5e1a8
--- /dev/null
+++ b/openspec/changes/mail-template-editor-saves/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkBeheerToegang: E-mailsjablonen with the registration, activation and password mails, subject and text https://identity.conduction.nl/screens/board?id=stackiq/SkBeheerToegang
diff --git a/openspec/changes/mail-template-editor-saves/specs/email-delivery/spec.md b/openspec/changes/mail-template-editor-saves/specs/email-delivery/spec.md
new file mode 100644
index 000000000..5fcf3e2ed
--- /dev/null
+++ b/openspec/changes/mail-template-editor-saves/specs/email-delivery/spec.md
@@ -0,0 +1,31 @@
+# email-delivery Specification (delta)
+
+## ADDED Requirements
+
+### Requirement: An administrator edits and saves a mail template (REQ-010)
+
+The mail settings SHALL let an administrator open a template, edit it, see the variables it may use, save it through `POST /api/email/templates/{templateName}`, and reset it to the default. Save SHALL report success only when the backend confirms it, and SHALL show the backend's error otherwise. The next mail of that kind SHALL use the saved template.
+
+#### Scenario: Saving an edited activation mail
+- GIVEN an administrator edits the activation template
+- WHEN they choose Save
+- THEN the template is stored, a success message shows, and reopening the template shows the edit
+
+#### Scenario: The backend refuses
+- GIVEN the template content is empty
+- WHEN the administrator chooses Save
+- THEN the error "Template content is required" shows and no success message appears
+
+#### Scenario: Reset to default
+- GIVEN an edited template
+- WHEN the administrator chooses Reset to default and saves
+- THEN the default text from `GET /api/email/templates/{templateName}/default` is stored
+
+### Requirement: The settings say when mail is not being sent (REQ-011)
+
+When the mail transport is `null` or not configured, the mail settings and the user and group settings SHALL show a warning that registration and activation mails are not sent until a transport is set, with a link to the transport settings.
+
+#### Scenario: A fresh install
+- GIVEN the transport is the default `null`
+- WHEN an administrator opens the mail settings
+- THEN a warning says no mail is sent until a transport is configured
diff --git a/openspec/changes/mail-template-editor-saves/tasks.md b/openspec/changes/mail-template-editor-saves/tasks.md
new file mode 100644
index 000000000..ee4d60d03
--- /dev/null
+++ b/openspec/changes/mail-template-editor-saves/tasks.md
@@ -0,0 +1,21 @@
+# Tasks: mail-template-editor-saves
+
+## Implementation tasks
+
+### Task 1: Save and reset call the backend (board SkBeheerToegang)
+- **spec_ref**: openspec/changes/mail-template-editor-saves/specs/email-delivery/spec.md#requirement-an-administrator-edits-and-saves-a-mail-template-req-010
+- **files**: `src/views/settings/sections/EmailConfiguration.vue`, `tests/vitest/emailTemplateSave.spec.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN `saveTemplate()` WHEN called THEN it posts `{template}` to `/api/email/templates/{templateName}` and shows success only on `success: true`; the "not yet implemented" text and the console call are gone
+ - GIVEN an error response WHEN saving THEN the backend message is shown with `showError`
+ - GIVEN Reset to default WHEN chosen THEN the editor loads `/api/email/templates/{templateName}/default`
+- [ ] Implement
+- [ ] Test (vitest `emailTemplateSave.spec.js`)
+
+### Task 2: Warning for the null transport
+- **spec_ref**: openspec/changes/mail-template-editor-saves/specs/email-delivery/spec.md#requirement-the-settings-say-when-mail-is-not-being-sent-req-011
+- **files**: `src/views/settings/sections/EmailConfiguration.vue`, `src/views/settings/sections/UserGroupsConfiguration.vue`, `tests/vitest/emailTransportWarning.spec.js`, `l10n/en.json`, `l10n/nl.json`
+- **acceptance_criteria**:
+ - GIVEN transport `null` or empty WHEN either section renders THEN an `NcNoteCard` warning says mails are not sent and links to the transport tab
+- [ ] Implement
+- [ ] Test (vitest `emailTransportWarning.spec.js`)
diff --git a/openspec/changes/mcp-full-action-surface/screens.md b/openspec/changes/mcp-full-action-surface/screens.md
new file mode 100644
index 000000000..2b96ccc27
--- /dev/null
+++ b/openspec/changes/mcp-full-action-surface/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkDashboard https://identity.conduction.nl/screens/board?id=stackiq/SkDashboard
diff --git a/openspec/changes/operations-record-reconciliation/screens.md b/openspec/changes/operations-record-reconciliation/screens.md
new file mode 100644
index 000000000..2297965d9
--- /dev/null
+++ b/openspec/changes/operations-record-reconciliation/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkDatakwaliteit https://identity.conduction.nl/screens/board?id=stackiq/SkDatakwaliteit
+- SkOrganisatie https://identity.conduction.nl/screens/board?id=stackiq/SkOrganisatie
diff --git a/openspec/changes/operations-sync-status-and-progress/screens.md b/openspec/changes/operations-sync-status-and-progress/screens.md
new file mode 100644
index 000000000..3422cb51a
--- /dev/null
+++ b/openspec/changes/operations-sync-status-and-progress/screens.md
@@ -0,0 +1,4 @@
+# Screens
+
+- SkDashboard https://identity.conduction.nl/screens/board?id=stackiq/SkDashboard
+- SkInstellingenGegevens https://identity.conduction.nl/screens/board?id=stackiq/SkInstellingenGegevens
diff --git a/openspec/changes/operations-technology-components/screens.md b/openspec/changes/operations-technology-components/screens.md
new file mode 100644
index 000000000..d175066cd
--- /dev/null
+++ b/openspec/changes/operations-technology-components/screens.md
@@ -0,0 +1,6 @@
+# Screens
+
+- SkApplicatie https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
+- SkCmdb https://identity.conduction.nl/screens/board?id=stackiq/SkCmdb
+- SkGebruikDetail https://identity.conduction.nl/screens/board?id=stackiq/SkGebruikDetail
+- SkModuleversie https://identity.conduction.nl/screens/board?id=stackiq/SkModuleversie
diff --git a/openspec/changes/organisation-parent-hierarchy-rbac-fix/screens.md b/openspec/changes/organisation-parent-hierarchy-rbac-fix/screens.md
new file mode 100644
index 000000000..c64ec1afd
--- /dev/null
+++ b/openspec/changes/organisation-parent-hierarchy-rbac-fix/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- No screen: An access rule fix for parent organisations.
diff --git a/openspec/changes/organisations-role-mapping-and-access-review/screens.md b/openspec/changes/organisations-role-mapping-and-access-review/screens.md
new file mode 100644
index 000000000..44df7d63b
--- /dev/null
+++ b/openspec/changes/organisations-role-mapping-and-access-review/screens.md
@@ -0,0 +1,3 @@
+# Screens
+
+- SkBeheerToegang https://identity.conduction.nl/screens/board?id=stackiq/SkBeheerToegang
diff --git a/openspec/changes/platform-properties/proposal.md b/openspec/changes/platform-properties/proposal.md
new file mode 100644
index 000000000..cb42208b3
--- /dev/null
+++ b/openspec/changes/platform-properties/proposal.md
@@ -0,0 +1,41 @@
+---
+kind: code
+depends_on: []
+---
+
+# Proposal: platform-properties
+
+## Summary
+
+Seven capability rows describe what stackiq gets from the platform it runs on rather than from its own code: it runs on your own Nextcloud, its data is reachable through a REST API, its look follows the Nextcloud theme, entries reach outside systems through flows and webhooks, every record keeps a history, and the store adds configuration from other organisations. All seven are built, but no requirement says them, so nothing can cite them and nothing stops a later change from breaking one. This change writes them down as a `platform-properties` spec, one requirement per property, each naming who provides it and how stackiq inherits it. pipelinq and shillinq solved the same rows this way.
+
+Two of the seven are drawn on board SkInstellingenUitwisseling with a stackiq half that is not built yet: a Webhooks section in the exchange settings and the "Pas versturen vanaf levenscyclusstatus" field on the service desk exchange. The board is the canon (decision 130), so the tasks build those two halves.
+
+## The rows this covers
+
+Source: stackiq `openspec/parity/capabilities.json`.
+
+- **share-self-hosted** Run the catalogue on your own infrastructure instead of the vendor's cloud. Provider: Nextcloud. Built: `appinfo/info.xml`, `stackiq-compose.yaml`.
+- **share-rest-api** Read and write catalogue data through a REST API. Provider: OpenRegister objects API, plus stackiq's own `/api/*` routes in `appinfo/routes.php`.
+- **ops-own-house-style** Apply the organisation's own colours and house style to the interface. Provider: Nextcloud theming (and thematiq).
+- **share-lifecycle-conditional-sync** Send entries to connected outside systems only once they reach a chosen lifecycle state. Provider: OpenRegister flows (trigger plus filter node), integriq source call. Board: SkInstellingenUitwisseling.
+- **share-webhooks** Notify another system automatically when a catalogue entry changes. Provider: OpenRegister webhooks; stackiq ships the ITSM outbound flow template. Board: SkInstellingenUitwisseling.
+- **ins-audit-trail** Look back at who changed what in the catalogue, and when. Provider: OpenRegister audit trail, shown in the History tab of 14 detail pages. Board: SkApplicatie (tab Historie).
+- **ops-plugins** Extend the product with plugins installed from a marketplace. Provider: the OpenRegister store plane, on the stackiq Store page. Board: SkFunctiesRoadmap (tab Uitbreidingen); the tab itself is built by the change `features-roadmap-page`.
+
+## What changes
+
+1. A new main capability `platform-properties` with seven requirements (REQ-PLAT-001 to REQ-PLAT-007).
+2. `@spec` tags on the files that carry each property.
+3. Checks that keep the properties true: an app-info test, a route posture test and a stylelint rule against hard-coded colours (a grep on development finds hex colours in 10 `.vue` files).
+4. The two missing halves drawn on SkInstellingenUitwisseling: the Webhooks section and the lifecycle threshold on the service desk exchange.
+
+## Screens
+
+See `screens.md`: SkInstellingenUitwisseling, SkApplicatie, SkFunctiesRoadmap.
+
+## Out of scope
+
+- Theming itself (Nextcloud and thematiq own it).
+- OpenRegister's webhook delivery, retries and signing (OpenRegister owns them; stackiq only lists and creates webhooks for its own schemas).
+- A catalogue-wide audit page. The overlay lists `audit-trail-view` as provided by OpenRegister.
diff --git a/openspec/changes/platform-properties/screens.md b/openspec/changes/platform-properties/screens.md
new file mode 100644
index 000000000..6ce8578d9
--- /dev/null
+++ b/openspec/changes/platform-properties/screens.md
@@ -0,0 +1,7 @@
+# Screens
+
+- SkInstellingenUitwisseling: the service desk exchange with "Pas versturen vanaf levenscyclusstatus" and the Webhooks section https://identity.conduction.nl/screens/board?id=stackiq/SkInstellingenUitwisseling
+- SkApplicatie: the Historie tab on an application https://identity.conduction.nl/screens/board?id=stackiq/SkApplicatie
+- SkFunctiesRoadmap: the Uitbreidingen tab with plug-ins from the store https://identity.conduction.nl/screens/board?id=stackiq/SkFunctiesRoadmap
+
+Hosting, the REST API and the house style are properties of the app, not a page in it.
diff --git a/openspec/changes/platform-properties/specs/platform-properties/spec.md b/openspec/changes/platform-properties/specs/platform-properties/spec.md
new file mode 100644
index 000000000..9d342e699
--- /dev/null
+++ b/openspec/changes/platform-properties/specs/platform-properties/spec.md
@@ -0,0 +1,115 @@
+# platform-properties Specification (delta)
+
+## Purpose
+
+What stackiq gets from the platform it runs on, independent of any one page: where it runs, how its data is reached, where its look comes from, how entries reach outside systems, how history is kept and how the store extends it. Covers rows share-self-hosted, share-rest-api, ops-own-house-style, share-lifecycle-conditional-sync, share-webhooks, ins-audit-trail and ops-plugins.
+
+## ADDED Requirements
+
+### Requirement: The catalogue runs on the customer's own Nextcloud (REQ-PLAT-001)
+
+stackiq SHALL install and run as a Nextcloud app on a server the customer operates, with OpenRegister as its only required app. It SHALL NOT need a service run by Conduction or another vendor to start, store data or serve its pages. Provider: Nextcloud; stackiq inherits hosting by being a Nextcloud app (`appinfo/info.xml`, licence EUPL-1.2).
+
+#### Scenario: An installation without access to vendor services
+- GIVEN a Nextcloud server with OpenRegister and no route to any Conduction service
+- WHEN an administrator enables stackiq and loads the catalogue register
+- THEN the dashboard, the Applications page and an application's detail page load and an application can be created
+
+#### Scenario: The licence is declared
+- GIVEN the repository at any release tag
+- WHEN `LICENSE` and `appinfo/info.xml` are read
+- THEN both name the EUPL-1.2
+
+### Requirement: Catalogue data is read and written through a REST API (REQ-PLAT-002)
+
+Every catalogue entity SHALL be an OpenRegister object, so OpenRegister's objects API (`/apps/openregister/api/objects/{register}/{schema}`) reads and writes it under the schema's authorization rules. stackiq's own routes under `/api/*` SHALL apply the same rules, and the stackiq pages SHALL use the same API as an outside client. Provider: OpenRegister.
+
+#### Scenario: An outside client reads applications
+- GIVEN a signed-in user with read access to the module schema
+- WHEN the client calls GET on the objects API for the catalogue register and the module schema
+- THEN it receives the same applications the Applications page lists for that user
+
+#### Scenario: A write without rights is refused
+- GIVEN a user without write access to the usage schema
+- WHEN the client posts a usage to the objects API
+- THEN the call is refused and no usage is stored
+
+### Requirement: The look comes from the theme, not the code (REQ-PLAT-003)
+
+stackiq's styles SHALL use the Nextcloud theme variables (and through them the NL Design System tokens thematiq supplies) for colours, fonts and radii, and SHALL NOT hard-code colour values in components except as the fallback of a `var()`. Provider: Nextcloud theming; stackiq inherits it through the CSS variables.
+
+#### Scenario: An organisation applies its house style
+- GIVEN an administrator sets a primary colour in Nextcloud theming
+- WHEN a user opens the stackiq dashboard and the Applications page
+- THEN buttons, links and active menu items use that colour
+
+#### Scenario: A hard-coded colour is added
+- GIVEN a component under `src/` with a hex colour outside a `var()` fallback
+- WHEN stylelint runs
+- THEN it fails on that line
+
+### Requirement: Entries go to outside systems only from a chosen lifecycle state (REQ-PLAT-004)
+
+An administrator SHALL be able to choose, per outside system, the lifecycle state from which stackiq sends an application ("Pas versturen vanaf levenscyclusstatus" on board SkInstellingenUitwisseling). Applications in an earlier state SHALL stay in stackiq. Provider: OpenRegister flows (an `object.updated` trigger and a filter node on the lifecycle status) and integriq's source call; stackiq ships the outbound flow template and writes the chosen state into its filter.
+
+#### Scenario: An application in planning is not sent
+- GIVEN the service desk exchange is set up with threshold "In gebruik"
+- WHEN a user saves an application in use with lifecycle state "Gepland"
+- THEN no message is sent to the service desk
+
+#### Scenario: The application reaches the threshold
+- GIVEN the same exchange
+- WHEN the lifecycle state of that application changes to "In gebruik"
+- THEN the outbound flow sends the application to the service desk once
+
+#### Scenario: The threshold is changed
+- GIVEN an administrator on the exchange settings
+- WHEN they choose "Concept" and save
+- THEN the filter of the published outbound flow uses "Concept" from the next change on
+
+### Requirement: Another system is notified when a catalogue entry changes (REQ-PLAT-005)
+
+The exchange settings SHALL show a Webhooks section that lists the webhooks on catalogue schemas with address, events and last delivery, and lets an administrator add, edit and remove one. A webhook SHALL fire on the chosen events (for example "Applicatie gewijzigd", "Contract gewijzigd", "Gebruik aangemaakt"). Provider: OpenRegister webhooks (delivery, retries, signing); stackiq lists and creates them for its own register and schemas only.
+
+#### Scenario: An administrator adds a webhook
+- GIVEN an administrator on the exchange settings
+- WHEN they add a webhook with an address and the event "Applicatie gewijzigd"
+- THEN the webhook is stored in OpenRegister for the module schema and appears in the list
+
+#### Scenario: A change is delivered
+- GIVEN that webhook
+- WHEN a user changes an application
+- THEN the address receives a message, and the list shows the status code and time of the last delivery
+
+#### Scenario: A webhook of another app stays out
+- GIVEN OpenRegister holds a webhook on another app's register
+- WHEN the Webhooks section loads
+- THEN that webhook is not listed
+
+### Requirement: Every record keeps its history (REQ-PLAT-006)
+
+Each catalogue detail page SHALL have a History tab that shows who changed the record, what changed and when, read from OpenRegister's audit trail for that object. Provider: OpenRegister audit trail; stackiq declares the tab (`widget type audit`) on its detail pages.
+
+#### Scenario: A user reads an application's history
+- GIVEN an application that a colleague changed yesterday
+- WHEN the user opens the application and the History tab
+- THEN the change is listed with the colleague's name, the changed fields and the time
+
+#### Scenario: Every detail page has the tab
+- GIVEN the merged manifest
+- WHEN its detail pages are read
+- THEN each one declares a History tab with an audit widget
+
+### Requirement: The store adds configuration published by others (REQ-PLAT-007)
+
+The Store page SHALL list the configuration sets and flows for stackiq that the configured registry offers, and let an administrator install one. Without a registry it SHALL show only the app's built-in items and make no network call. Provider: the OpenRegister store plane; stackiq declares the page and its store types (`openregister.configset`, `openregister.flows`) and writes no store controller.
+
+#### Scenario: No registry is configured
+- GIVEN a default install
+- WHEN an administrator opens the Store page
+- THEN the built-in items are listed and no outside request is made
+
+#### Scenario: An administrator installs a flow
+- GIVEN a registry that offers a stackiq flow
+- WHEN the administrator installs it
+- THEN the flow appears on the Flows page
diff --git a/openspec/changes/platform-properties/tasks.md b/openspec/changes/platform-properties/tasks.md
new file mode 100644
index 000000000..eaea3fff1
--- /dev/null
+++ b/openspec/changes/platform-properties/tasks.md
@@ -0,0 +1,60 @@
+# Tasks: platform-properties
+
+## Implementation tasks
+
+### Task 1: Tag the files that carry each property
+- **spec_ref**: openspec/changes/platform-properties/specs/platform-properties/spec.md
+- **files**: `appinfo/info.xml`, `lib/AppInfo/Application.php`, `src/manifest.json`, `lib/Settings/flows/itsm-outbound-applications.json`
+- **acceptance_criteria**:
+ - GIVEN `appinfo/info.xml` WHEN read THEN an XML comment above `