diff --git a/lib/Service/AangebodenGebruikService.php b/lib/Service/AangebodenGebruikService.php index 6b5cc854a..72bd029f5 100644 --- a/lib/Service/AangebodenGebruikService.php +++ b/lib/Service/AangebodenGebruikService.php @@ -56,6 +56,8 @@ * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.CamelCaseVariableName) * @SuppressWarnings(PHPMD.CamelCaseParameterName) + * + * @spec openspec/specs/aangeboden-gebruik-api/spec.md#requirement-the-system-shall-list-offered-usage-records-scoped-to-the-callers-role-req-001 */ class AangebodenGebruikService { /** @@ -321,212 +323,240 @@ public function getGebruiksWhereAfnemer(array $options = []): array { * * @return array searchObjectsPaginated result with koppelingen and gebruiks for the UUID. * - * @throws Exception When OpenRegister service is not available. - * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $isAmbtenaar is a simple privilege toggle * @spec openspec/specs/aangeboden-gebruik-api/spec.md */ public function getKoppelingenGebruikByUuid(string $uuid, array $options = [], bool $isAmbtenaar = false): array { - $this->logger->info( - 'Getting koppelingen and gebruiks for UUID with extended access', - [ - 'uuid' => $uuid, - 'options' => $options, - 'isAmbtenaar' => $isAmbtenaar, - ] - ); + $request = [ + 'uuid' => $uuid, + 'options' => $options, + 'isAmbtenaar' => $isAmbtenaar, + ]; + $this->logger->info('Getting koppelingen and gebruiks for UUID with extended access', $request); try { - // Validate input. - if (empty($uuid) === true) { - return [ - 'results' => [], - 'total' => 0, - 'page' => 1, - 'pages' => 0, - 'limit' => 20, - 'offset' => 0, - 'error' => 'UUID is required', - ]; - } + return $this->koppelingenGebruik(request: $request); + } catch (Exception $e) { + $this->logger->error( + 'Failed to get koppelingen-gebruik by UUID', + [ + 'uuid' => $uuid, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ] + ); - // Get ObjectService from OpenRegister. - $objectService = $this->getObjectService(); + return $this->emptyKoppelingenPage(error: 'Failed to retrieve objects: ' . $e->getMessage()); + } + }//end getKoppelingenGebruikByUuid() - // Get voorzieningen configuration. - $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); - $registerId = $voorzieningenConfig['register'] ?? null; - $gebruikSchema = $voorzieningenConfig['gebruik_schema'] ?? null; - $koppeligenSchema = $voorzieningenConfig['koppeling_schema'] ?? null; + /** + * The koppelingen and gebruiks for a uuid, when the caller may read them. + * + * @param array{uuid: string, options: array, isAmbtenaar: bool} $request The uuid, query options and ambtenaar flag + * + * @return array searchObjectsPaginated result, or an empty page + * + * @throws Exception When OpenRegister or the voorzieningen configuration is not available. + */ + private function koppelingenGebruik(array $request): array { + // Validate input. + if (empty($request['uuid']) === true) { + return $this->emptyKoppelingenPage(error: 'UUID is required'); + } - if ($registerId === null || $gebruikSchema === null || $koppeligenSchema === null) { - throw new Exception( - 'Voorzieningen configuration not found. Please configure the schemas in the admin panel.' - ); - } + // Get ObjectService from OpenRegister. + $request['objectService'] = $this->getObjectService(); - // Check access permissions. - $currentOrg = $this->getCurrentOrganisation(); - $hasAccess = false; + // Get voorzieningen configuration. + $request['config'] = $this->settingsService->getVoorzieningenConfig(); + $registerId = $request['config']['register'] ?? null; + $gebruikSchema = $request['config']['gebruik_schema'] ?? null; + $koppeligenSchema = $request['config']['koppeling_schema'] ?? null; - if ($isAmbtenaar === true) { - // Ambtenaar always has access. - $hasAccess = true; - } elseif ($currentOrg !== null) { - // Check if the application/module is owned by user's organization. - try { - $appObject = $objectService->find(id: $uuid, _rbac: false, _multitenancy: false); - if ($appObject !== null) { - $appData = $appObject->getObject(); - $ownerOrg = $appData['@self']['organisation'] ?? null; - $hasAccess = ($ownerOrg === $currentOrg); - } - } catch (Exception $e) { - $this->logger->warning( - 'Failed to check ownership for UUID', - [ - 'uuid' => $uuid, - 'error' => $e->getMessage(), - ] - ); - } - }//end if + if ($registerId === null || $gebruikSchema === null || $koppeligenSchema === null) { + throw new Exception( + 'Voorzieningen configuration not found. Please configure the schemas in the admin panel.' + ); + } - if ($hasAccess === false) { - return [ - 'results' => [], - 'total' => 0, - 'page' => 1, - 'pages' => 0, - 'limit' => 20, - 'offset' => 0, - ]; - } + // Check access permissions. + if ($this->mayReadKoppelingen(request: $request) === false) { + return $this->emptyKoppelingenPage(error: null); + } - // Get organization filter if provided (for ambtenaar). - $organisationFilter = null; - if ($isAmbtenaar === true && isset($options['organisation']) === true) { - $organisationFilter = $options['organisation']; - } + // Build search query using ObjectService's buildSearchQuery. + $searchQuery = $request['objectService']->buildSearchQuery($request['options']); - // Build search query using ObjectService's buildSearchQuery. - $searchQuery = $objectService->buildSearchQuery($options); + // Add register and schema filters. + $searchQuery['@self']['register'] = $registerId; + $searchQuery['@self']['schema'] = [$gebruikSchema, $koppeligenSchema]; - // Add register and schema filters. - $searchQuery['@self']['register'] = $registerId; - $searchQuery['@self']['schema'] = [$gebruikSchema, $koppeligenSchema]; + // Force database source. + $searchQuery['_source'] = 'database'; - // Force database source. - $searchQuery['_source'] = 'database'; + $searchResult = $this->searchKoppelingen(request: $request, searchQuery: $searchQuery); - // Check if UUID is an organisation UUID by trying to fetch it and checking its schema. - $isOrganisationUuid = false; - try { - $uuidObject = $objectService->find(id: $uuid, _rbac: false, _multitenancy: false); - if ($uuidObject !== null) { - $uuidData = $uuidObject->getObject(); - $uuidSchema = $uuidData['@self']['schema'] ?? null; - $organisationSchemaId = $voorzieningenConfig['organisatie_schema'] ?? '15'; - $isOrganisationUuid = ($uuidSchema === $organisationSchemaId); - } - } catch (Exception $e) { - $this->logger->debug( - 'Could not fetch UUID object, assuming it is not an organisation', - [ - 'uuid' => $uuid, - ] - ); - } + $this->logger->debug( + 'Koppelingen-gebruik by UUID search completed', + [ + 'uuid' => $request['uuid'], + 'total' => $searchResult['total'] ?? 0, + 'results_count' => count($searchResult['results'] ?? []), + ] + ); - // Handle organisation UUID filtering differently from suite/module UUIDs. - if ($isOrganisationUuid === true) { - // For organisation UUIDs, filter by @self.organisation. - $searchQuery['@self']['organisation'] = $uuid; + return $searchResult; + }//end koppelingenGebruik() - // Apply additional organisation filter if provided by ambtenaar. - if ($organisationFilter !== null) { - $this->logger->warning( - 'Organisation filter parameter is ignored when UUID is already an organisation', - [ - 'uuid' => $uuid, - 'filter' => $organisationFilter, - ] - ); - } + /** + * Whether the caller may read the koppelingen of a uuid: an ambtenaar always, anyone + * else only when their active organisation owns the object behind the uuid. + * + * @param array $request The uuid, ambtenaar flag and object service + * + * @return bool True when access is granted + */ + private function mayReadKoppelingen(array $request): bool { + $currentOrg = $this->getCurrentOrganisation(); + if ($request['isAmbtenaar'] === true) { + return true; + } - $this->logger->debug( - 'Executing koppelingen-gebruik by organisation UUID', - [ - 'uuid' => $uuid, - 'query' => $searchQuery, - ] - ); + if ($currentOrg === null) { + return false; + } - // Execute paginated search without 'uses' parameter. - $searchResult = $objectService->searchObjectsPaginated( - query: $searchQuery, - _rbac: false, - _multitenancy: false, - deleted: false - ); - }//end if + // Check if the application/module is owned by user's organization. + try { + $appObject = $request['objectService']->find(id: $request['uuid'], _rbac: false, _multitenancy: false); + if ($appObject !== null) { + $appData = $appObject->getObject(); + $ownerOrg = $appData['@self']['organisation'] ?? null; + return $ownerOrg === $currentOrg; + } + } catch (Exception $e) { + $this->logger->warning( + 'Failed to check ownership for UUID', + [ + 'uuid' => $request['uuid'], + 'error' => $e->getMessage(), + ] + ); + } - if ($isOrganisationUuid === false) { - // For suite/module UUIDs, use 'uses' parameter to filter by relations. - // Add organization filter if provided. - if ($organisationFilter !== null) { - $searchQuery['@self']['organisation'] = $organisationFilter; - } + return false; + }//end mayReadKoppelingen() + + /** + * Run the koppelingen search: by @self.organisation for an organisation uuid, by relation otherwise. + * + * An ambtenaar's organisation filter narrows a relation search; it is ignored for an organisation uuid. + * + * @param array $request The uuid, options, ambtenaar flag, object service and configuration + * @param array $searchQuery The query with register, schema and source set + * + * @return array searchObjectsPaginated result + */ + private function searchKoppelingen(array $request, array $searchQuery): array { + $uuid = $request['uuid']; + + // Get organization filter if provided (for ambtenaar). + $organisationFilter = null; + if ($request['isAmbtenaar'] === true && isset($request['options']['organisation']) === true) { + $organisationFilter = $request['options']['organisation']; + } + + if ($this->isOrganisationUuid(request: $request) === true) { + // For organisation UUIDs, filter by @self.organisation. + $searchQuery['@self']['organisation'] = $uuid; - $this->logger->debug( - 'Executing koppelingen-gebruik by suite/module UUID', + if ($organisationFilter !== null) { + $this->logger->warning( + 'Organisation filter parameter is ignored when UUID is already an organisation', [ 'uuid' => $uuid, - 'query' => $searchQuery, + 'filter' => $organisationFilter, ] ); + } - // Execute paginated search using 'uses' parameter to filter by UUID in relations. - $searchResult = $objectService->searchObjectsPaginated( - query: $searchQuery, - _rbac: false, - _multitenancy: false, - deleted: false, - uses: $uuid - ); - }//end if + $this->logger->debug('Executing koppelingen-gebruik by organisation UUID', ['uuid' => $uuid, 'query' => $searchQuery]); - $this->logger->debug( - 'Koppelingen-gebruik by UUID search completed', - [ - 'uuid' => $uuid, - 'total' => $searchResult['total'] ?? 0, - 'results_count' => count($searchResult['results'] ?? []), - ] + return $request['objectService']->searchObjectsPaginated( + query: $searchQuery, + _rbac: false, + _multitenancy: false, + deleted: false ); + }//end if - return $searchResult; + // For suite/module UUIDs, use 'uses' parameter to filter by relations. + if ($organisationFilter !== null) { + $searchQuery['@self']['organisation'] = $organisationFilter; + } + + $this->logger->debug('Executing koppelingen-gebruik by suite/module UUID', ['uuid' => $uuid, 'query' => $searchQuery]); + + return $request['objectService']->searchObjectsPaginated( + query: $searchQuery, + _rbac: false, + _multitenancy: false, + deleted: false, + uses: $uuid + ); + }//end searchKoppelingen() + + /** + * Whether a uuid names an organisation: its object's schema is the configured organisation schema. + * + * @param array $request The uuid, object service and configuration + * + * @return bool True for an organisation uuid; false when the object cannot be fetched + */ + private function isOrganisationUuid(array $request): bool { + try { + $uuidObject = $request['objectService']->find(id: $request['uuid'], _rbac: false, _multitenancy: false); + if ($uuidObject !== null) { + $uuidData = $uuidObject->getObject(); + $uuidSchema = $uuidData['@self']['schema'] ?? null; + $organisationSchemaId = $request['config']['organisatie_schema'] ?? '15'; + return $uuidSchema === $organisationSchemaId; + } } catch (Exception $e) { - $this->logger->error( - 'Failed to get koppelingen-gebruik by UUID', + $this->logger->debug( + 'Could not fetch UUID object, assuming it is not an organisation', [ - 'uuid' => $uuid, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), + 'uuid' => $request['uuid'], ] ); + } - return [ - 'results' => [], - 'total' => 0, - 'page' => 1, - 'pages' => 0, - 'limit' => 20, - 'offset' => 0, - 'error' => 'Failed to retrieve objects: ' . $e->getMessage(), - ]; - }//end try - }//end getKoppelingenGebruikByUuid() + return false; + }//end isOrganisationUuid() + + /** + * An empty result page, with an error when one is given. + * + * @param string|null $error The error to report, or null + * + * @return array The empty page + */ + private function emptyKoppelingenPage(?string $error): array { + $page = [ + 'results' => [], + 'total' => 0, + 'page' => 1, + 'pages' => 0, + 'limit' => 20, + 'offset' => 0, + ]; + if ($error !== null) { + $page['error'] = $error; + } + + return $page; + }//end emptyKoppelingenPage() /** * Get all gebruiks objects (ignoring RBAC and multitenancy) - restricted to ambtenaar group diff --git a/lib/Service/ContactpersoonService.php b/lib/Service/ContactpersoonService.php index c157ca85e..3a0b12219 100644 --- a/lib/Service/ContactpersoonService.php +++ b/lib/Service/ContactpersoonService.php @@ -193,7 +193,7 @@ public function processContactpersoon(object $contactPersonObject, bool $isUpdat 'entitiesUpdated' => 0, ]; $organisationEntity = $organizationSyncService->ensureOrganisationEntityPublic( - orgObject: $orgObject, + organisationObject: $orgObject, stats: $backupStats ); $this->logger->info( diff --git a/lib/Service/OrganizationSyncService.php b/lib/Service/OrganizationSyncService.php index f8dd147d8..7cc7e4d0a 100644 --- a/lib/Service/OrganizationSyncService.php +++ b/lib/Service/OrganizationSyncService.php @@ -56,6 +56,8 @@ * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.CamelCaseVariableName) * @SuppressWarnings(PHPMD.CamelCaseParameterName) + * + * @spec openspec/specs/organization-sync/spec.md#requirement-the-system-shall-process-individual-organisationcontact-records-and-ensure-their-openregister-entity-req-002 */ class OrganizationSyncService { @@ -428,7 +430,7 @@ public function performOrganizationsSync(int $batchSize = 50, int $maxExecutionS _multitenancy: false ); - $this->ensureOrganisationEntity(organisationObject: $object, stats: $stats, sendEmails: false); + $this->ensureOrganisationEntity(organisationObject: $object, run: ['stats' => &$stats, 'sendEmails' => false]); $stats['organizationsProcessed']++; } catch (\Exception $e) { $this->handleSyncError(operation: 'OrganizationSync', identifier: (string)$row['uuid'], exception: $e, stats: $stats); @@ -892,7 +894,7 @@ private function processOrganisationObject( ); // Step 1: Ensure organisation entity exists. - $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organisationObject, stats: $stats); + $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organisationObject, run: ['stats' => &$stats, 'sendEmails' => true]); if ($organisationEntity === null) { $this->logger->warning( 'OrganizationSyncService: Could not ensure organisation entity', @@ -946,8 +948,7 @@ private function processOrganisationObject( public function ensureOrganisationEntityPublic(object $organisationObject, array &$stats, bool $sendEmails = true): ?object { return $this->ensureOrganisationEntity( organisationObject: $organisationObject, - stats: $stats, - sendEmails: $sendEmails + run: ['stats' => &$stats, 'sendEmails' => $sendEmails] ); }//end ensureOrganisationEntityPublic() @@ -956,40 +957,16 @@ public function ensureOrganisationEntityPublic(object $organisationObject, array * Ensures organisation entity exists for organisatie object. * * @param object $organisationObject The organisatie object. - * @param array $stats Statistics array (passed by reference). - * @param bool $sendEmails Whether to send emails. + * @param array $run The sync run: `stats` (held by reference) and `sendEmails`, + * whether to send registration and activation e-mails. * * @return object|null The organisation entity or null on failure. - * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $sendEmails is a simple notification toggle */ - private function ensureOrganisationEntity(object $organisationObject, array &$stats, bool $sendEmails = true): ?object { + private function ensureOrganisationEntity(object $organisationObject, array $run): ?object { try { // Get the full object data - the passed object might not have all fields populated. $organisationId = $organisationObject->getUuid(); - - // Fetch the complete object from the database to ensure we have all data. - try { - $fullObject = $this->objectService->find( - id: $organisationId, - register: $organisationObject->getRegister(), - schema: $organisationObject->getSchema(), - _rbac: false, - _multitenancy: false - ); - if (empty($fullObject) === false) { - $organisationObject = $fullObject; - } - } catch (\Exception $e) { - $this->logger->warning( - 'Could not fetch full organisation object, using provided object', - [ - 'organisatieId' => $organisationId, - 'error' => $e->getMessage(), - ] - ); - }//end try - + $organisationObject = $this->fullOrganisationObject(organisationObject: $organisationObject); $objectData = $organisationObject->getObject(); $this->logger->debug( @@ -1004,197 +981,262 @@ private function ensureOrganisationEntity(object $organisationObject, array &$st // Get configuration for object updates. $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); - $register = ($voorzieningenConfig['register'] ?? ''); - $organizationSchema = ($voorzieningenConfig['organisatie_schema'] ?? ''); + $run['organisationId'] = $organisationId; + $run['organisationObject'] = $organisationObject; + $run['objectData'] = $objectData; + $run['register'] = ($voorzieningenConfig['register'] ?? ''); + $run['organizationSchema'] = ($voorzieningenConfig['organisatie_schema'] ?? ''); // Try to find existing organisation entity. - try { $organisationEntity = $this->organisationMapper->findByUuid($organisationId); + return $this->refreshOrganisationEntity(organisationEntity: $organisationEntity, run: $run); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return $this->adoptOrganisationEntityBySlug(run: $run) ?? $this->createOrganisationEntity(run: $run); + } + } catch (\Exception $e) { + $this->logger->error( + '💥 ENSURE ORGANISATION ENTITY EXCEPTION', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationObject->getId(), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ] + ); + return null; + }//end try - // Entity exists - update it if needed. - $status = strtolower(($objectData['status'] ?? 'actief')); - $shouldBeActive = in_array($status, ['actief', 'active']) === true; + }//end ensureOrganisationEntity() - $this->logger->debug( - 'Existing entity found', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'entityId' => $organisationEntity->getId(), - 'shouldBeActive' => $shouldBeActive, - 'needsUpdate' => $organisationEntity->getActive() !== $shouldBeActive, - ] - ); + /** + * The organisatie object as stored, or the one given when it cannot be fetched. + * + * @param object $organisationObject The organisatie object as passed in. + * + * @return object The complete organisatie object + */ + private function fullOrganisationObject(object $organisationObject): object { + $organisationId = $organisationObject->getUuid(); + try { + $fullObject = $this->objectService->find( + id: $organisationId, + register: $organisationObject->getRegister(), + schema: $organisationObject->getSchema(), + _rbac: false, + _multitenancy: false + ); + if (empty($fullObject) === false) { + return $fullObject; + } + } catch (\Exception $e) { + $this->logger->warning( + 'Could not fetch full organisation object, using provided object', + [ + 'organisatieId' => $organisationId, + 'error' => $e->getMessage(), + ] + ); + }//end try - if ($organisationEntity->getActive() !== $shouldBeActive) { - $this->logger->debug( - 'Updating entity status', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'oldActive' => $organisationEntity->getActive(), - 'newActive' => $shouldBeActive, - ] - ); + return $organisationObject; + }//end fullOrganisationObject() - $wasActive = $organisationEntity->getActive(); - $organisationEntity->setActive($shouldBeActive); - $this->organisationMapper->save($organisationEntity); - $stats['entitiesUpdated']++; + /** + * Bring an existing organisation entity's active flag in line with the object's status, + * mail an organisation that became active, and make the entity the object's owner. + * + * @param object $organisationEntity The existing organisation entity. + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return object The organisation entity + */ + private function refreshOrganisationEntity(object $organisationEntity, array $run): object { + $organisationId = $run['organisationId']; - // Send activation email if organization became active. - if ($sendEmails !== false && $shouldBeActive === true && $wasActive === false) { - $this->logger->info( - '[FLOW] Sending organization activation email', - ['organisatieId' => $organisationId] - ); - $emailSent = $this->sendOrganizationActivationEmail(organizationData: $objectData); - if (empty($emailSent) === false) { - $this->logger->info( - '📧 Organization activation email sent successfully', - ['organisatieId' => $organisationId] - ); - } + // Entity exists - update it if needed. + $status = strtolower(($run['objectData']['status'] ?? 'actief')); + $shouldBeActive = in_array($status, ['actief', 'active']) === true; - if (empty($emailSent) === true) { - $this->logger->info( - '📧 Organization activation email not sent (disabled or not configured)', - ['organisatieId' => $organisationId] - ); - } - }//end if - }//end if + $this->logger->debug( + 'Existing entity found', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'entityId' => $organisationEntity->getId(), + 'shouldBeActive' => $shouldBeActive, + 'needsUpdate' => $organisationEntity->getActive() !== $shouldBeActive, + ] + ); - // Update organisatie object owner to organisation entity UUID. - $this->updateOrganisationObjectOwner( - organisationObject: $organisationObject, - organisationEntity: $organisationEntity, - register: $register, - organizationSchema: $organizationSchema - ); + if ($organisationEntity->getActive() !== $shouldBeActive) { + $this->logger->debug( + 'Updating entity status', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'oldActive' => $organisationEntity->getActive(), + 'newActive' => $shouldBeActive, + ] + ); - return $organisationEntity; - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - // Entity not found by UUID — try finding by slug before creating. - // This handles the case where the entity exists but with a different UUID (e.g. from slug collision). - $orgName = ($objectData['name'] ?? ''); - if (empty($orgName) === false) { - $slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', strtolower($orgName))); - $slug = trim($slug, '-'); - try { - $organisationEntity = $this->organisationMapper->findBySlug($slug); - $this->logger->info( - 'OrganizationSyncService: Found existing entity by slug, updating UUID to match object', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'oldEntityUuid' => $organisationEntity->getUuid(), - 'slug' => $slug, - ] - ); + $wasActive = $organisationEntity->getActive(); + $organisationEntity->setActive($shouldBeActive); + $this->organisationMapper->save($organisationEntity); + $run['stats']['entitiesUpdated']++; - // Update the entity's UUID to match the object UUID so future lookups work. - $organisationEntity->setUuid($organisationId); - $this->organisationMapper->save($organisationEntity); - $stats['entitiesUpdated']++; + // Send activation email if organization became active. + if ($run['sendEmails'] !== false && $shouldBeActive === true && $wasActive === false) { + $this->mailOrganisation(kind: 'activation', run: $run); + } + }//end if - // Update organisatie object owner to this entity. - $this->updateOrganisationObjectOwner( - organisationObject: $organisationObject, - organisationEntity: $organisationEntity, - register: $register, - organizationSchema: $organizationSchema - ); + // Update organisatie object owner to organisation entity UUID. + $this->makeEntityOwner(organisationEntity: $organisationEntity, run: $run); - return $organisationEntity; - } catch (\OCP\AppFramework\Db\DoesNotExistException $slugEx) { - // Not found by slug either — proceed with creation. - }//end try - }//end if + return $organisationEntity; + }//end refreshOrganisationEntity() - $this->logger->debug( - 'Creating new organisation entity', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'name' => ($objectData['name'] ?? 'Unknown'), - ] - ); + /** + * Adopt an entity that has the organisation's slug but another uuid: it takes the object's uuid. + * + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return object|null The adopted entity, or null when the object has no name or no entity has its slug + */ + private function adoptOrganisationEntityBySlug(array $run): ?object { + // Entity not found by UUID — try finding by slug before creating. + // This handles the case where the entity exists but with a different UUID (e.g. from slug collision). + $orgName = ($run['objectData']['name'] ?? ''); + if (empty($orgName) === true) { + return null; + } - $organisationEntity = $this->organisationService->createOrganisationInOpenRegister($objectData); - if (empty($organisationEntity) === false) { - $stats['entitiesCreated']++; - $this->logger->debug( - 'New organisation entity created', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'entityId' => $organisationEntity->getId(), - 'active' => $organisationEntity->getActive(), - 'name' => $organisationEntity->getName(), - ] - ); + $slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', strtolower($orgName))); + $slug = trim($slug, '-'); + try { + $organisationEntity = $this->organisationMapper->findBySlug($slug); + $this->logger->info( + 'OrganizationSyncService: Found existing entity by slug, updating UUID to match object', + [ + 'app' => 'stackiq', + 'organisatieId' => $run['organisationId'], + 'oldEntityUuid' => $organisationEntity->getUuid(), + 'slug' => $slug, + ] + ); - // Send registration email for new organization (skip for backfill). - if (empty($sendEmails) === false) { - $this->logger->info( - '[FLOW] Sending organization registration email', - ['organisatieId' => $organisationId] - ); - $emailSent = $this->sendOrganizationRegistrationEmail(organizationData: $objectData); - if (empty($emailSent) === false) { - $this->logger->info( - '📧 Organization registration email sent successfully', - ['organisatieId' => $organisationId] - ); - } + // Update the entity's UUID to match the object UUID so future lookups work. + $organisationEntity->setUuid($run['organisationId']); + $this->organisationMapper->save($organisationEntity); + $run['stats']['entitiesUpdated']++; - if (empty($emailSent) === true) { - $this->logger->info( - '📧 Organization registration email not sent (disabled or not configured)', - ['organisatieId' => $organisationId] - ); - } - }//end if + // Update organisatie object owner to this entity. + $this->makeEntityOwner(organisationEntity: $organisationEntity, run: $run); - // Update organisatie object owner to organisation entity UUID. - $this->updateOrganisationObjectOwner( - organisationObject: $organisationObject, - organisationEntity: $organisationEntity, - register: $register, - organizationSchema: $organizationSchema - ); - }//end if + return $organisationEntity; + } catch (\OCP\AppFramework\Db\DoesNotExistException $slugEx) { + // Not found by slug either — proceed with creation. + return null; + }//end try + }//end adoptOrganisationEntityBySlug() - if (empty($organisationEntity) === true) { - $this->logger->error( - '❌ ORGANISATION ENTITY CREATION FAILED', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - ] - ); - } + /** + * Create the organisation entity, mail the new organisation and make the entity the object's owner. + * + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return object|null The new entity, or what creation returned when it failed + */ + private function createOrganisationEntity(array $run): ?object { + $organisationId = $run['organisationId']; + $this->logger->debug( + 'Creating new organisation entity', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'name' => ($run['objectData']['name'] ?? 'Unknown'), + ] + ); - return $organisationEntity; - }//end try - } catch (\Exception $e) { + $organisationEntity = $this->organisationService->createOrganisationInOpenRegister($run['objectData']); + if (empty($organisationEntity) === true) { $this->logger->error( - '💥 ENSURE ORGANISATION ENTITY EXCEPTION', + '❌ ORGANISATION ENTITY CREATION FAILED', [ 'app' => 'stackiq', - 'organisatieId' => $organisationObject->getId(), - 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), + 'organisatieId' => $organisationId, ] ); - return null; - }//end try + return $organisationEntity; + } - }//end ensureOrganisationEntity() + $run['stats']['entitiesCreated']++; + $this->logger->debug( + 'New organisation entity created', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'entityId' => $organisationEntity->getId(), + 'active' => $organisationEntity->getActive(), + 'name' => $organisationEntity->getName(), + ] + ); + + // Send registration email for new organization (skip for backfill). + if (empty($run['sendEmails']) === false) { + $this->mailOrganisation(kind: 'registration', run: $run); + } + + // Update organisatie object owner to organisation entity UUID. + $this->makeEntityOwner(organisationEntity: $organisationEntity, run: $run); + + return $organisationEntity; + }//end createOrganisationEntity() + + /** + * Send an organisation its registration or activation e-mail and log whether it went out. + * + * @param string $kind 'registration' or 'activation'. + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return void + */ + private function mailOrganisation(string $kind, array $run): void { + $context = ['organisatieId' => $run['organisationId']]; + $this->logger->info('[FLOW] Sending organization ' . $kind . ' email', $context); + if ($kind === 'activation') { + $emailSent = $this->sendOrganizationActivationEmail(organizationData: $run['objectData']); + } else { + $emailSent = $this->sendOrganizationRegistrationEmail(organizationData: $run['objectData']); + } + + if (empty($emailSent) === false) { + $this->logger->info('📧 Organization ' . $kind . ' email sent successfully', $context); + return; + } + + $this->logger->info('📧 Organization ' . $kind . ' email not sent (disabled or not configured)', $context); + }//end mailOrganisation() + + /** + * Make the organisation entity the owner and organisation of the run's organisatie object. + * + * @param object $organisationEntity The organisation entity. + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return void + */ + private function makeEntityOwner(object $organisationEntity, array $run): void { + $this->updateOrganisationObjectOwner( + organisationObject: $run['organisationObject'], + organisationEntity: $organisationEntity, + register: $run['register'], + organizationSchema: $run['organizationSchema'] + ); + }//end makeEntityOwner() /** * Safely sends organization registration email with error handling @@ -1724,7 +1766,7 @@ public function processSpecificOrganization($organizationObject): array { ] ); - $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organizationObject, stats: $stats); + $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organizationObject, run: ['stats' => &$stats, 'sendEmails' => true]); if (empty($organisationEntity) === false) { $stats['organizationsProcessed']++; @@ -1761,11 +1803,7 @@ public function processSpecificOrganization($organizationObject): array { ] ); - $this->processRelatedContactPersons( - organizationUuid: $organizationUuid, - organizationObject: $organizationObject, - stats: $stats - ); + $this->processRelatedContactPersons(organizationUuid: $organizationUuid, stats: $stats); }//end if if (empty($organisationEntity) === true) { @@ -1956,14 +1994,11 @@ private function processNestedContactPersons($organizationObject, array &$stats) * Process related contactpersoon objects that have this organization in their organisation property * * @param string $organizationUuid The organization UUID. - * @param \OCA\OpenRegister\Db\ObjectEntity $organizationObject The organization object. - * @param array $stats The statistics array to update. + * @param array $stats The statistics array to update. * * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) $organizationObject reserved for future contact person enrichment */ - private function processRelatedContactPersons(string $organizationUuid, $organizationObject, array &$stats): void { + private function processRelatedContactPersons(string $organizationUuid, array &$stats): void { try { $this->logger->debug( 'Finding related contact persons', @@ -1975,274 +2010,349 @@ private function processRelatedContactPersons(string $organizationUuid, $organiz // Get configuration. $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); - $register = ($voorzieningenConfig['register'] ?? ''); - $contactSchema = ($voorzieningenConfig['contactpersoon_schema'] ?? ''); + $run = [ + 'organizationUuid' => $organizationUuid, + 'register' => ($voorzieningenConfig['register'] ?? ''), + 'contactSchema' => ($voorzieningenConfig['contactpersoon_schema'] ?? ''), + ]; - if (empty($register) === true || empty($contactSchema) === true) { + if (empty($run['register']) === true || empty($run['contactSchema']) === true) { $this->logger->warning( '[FLOW] Related contact processing skipped - configuration missing', [ 'organizationId' => $organizationUuid, - 'register' => $register, - 'contactSchema' => $contactSchema, + 'register' => $run['register'], + 'contactSchema' => $run['contactSchema'], ] ); return; } - // Find all contactpersoon objects that have this organization in their organisation property. + $relatedContacts = $this->findRelatedContacts(run: $run); + if (empty($relatedContacts) === true) { + return; + } - // Search for contactpersoon objects with this organization reference. - // Try both 'organization' and 'organisation' field names. - $query = [ - '@self' => [ - 'register' => (int)$register, - 'schema' => (int)$contactSchema, - ], - 'organization' => $organizationUuid, - '_limit' => 500, - ]; + $this->logger->info( + '👥 PROCESSING RELATED CONTACT PERSONS', + [ + 'app' => 'stackiq', + 'organizationId' => $organizationUuid, + 'contactCount' => count($relatedContacts), + ] + ); + + $run['stats'] = &$stats; + foreach ($relatedContacts as $contactObject) { + $this->processRelatedContact(contactObject: $contactObject, run: $run); + } + } catch (\Exception $e) { + $this->logger->error( + '[FLOW] Failed to process related contact persons', + [ + 'organizationId' => $organizationUuid, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + $stats['errors'][] = 'Failed to process related contact persons: ' . $e->getMessage(); + }//end try + + }//end processRelatedContactPersons() + + /** + * Find the contactpersoon objects of an organisation: by their organization field, then by + * their organisation field, then from the organisation's own contactpersonen array. + * + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return array The related contact objects, empty when none are found + */ + private function findRelatedContacts(array $run): array { + $organizationUuid = $run['organizationUuid']; + + // Search for contactpersoon objects with this organization reference. + // Try both 'organization' and 'organisation' field names. + $query = [ + '@self' => [ + 'register' => (int)$run['register'], + 'schema' => (int)$run['contactSchema'], + ], + 'organization' => $organizationUuid, + '_limit' => 500, + ]; + + $this->logger->info( + '[FLOW] Searching for related contact persons with organisatie field', + [ + 'organizationId' => $organizationUuid, + 'register' => $run['register'], + 'contactSchema' => $run['contactSchema'], + 'query' => $query, + ] + ); + + $relatedContacts = $this->objectService->searchObjects($query); + + // If not found, try with 'organisation' field. + if (empty($relatedContacts) === true) { + $query['organisation'] = $organizationUuid; + unset($query['organization']); $this->logger->info( - '[FLOW] Searching for related contact persons with organisatie field', + '[FLOW] Retrying search with organisation field', [ 'organizationId' => $organizationUuid, - 'register' => $register, - 'contactSchema' => $contactSchema, 'query' => $query, ] ); $relatedContacts = $this->objectService->searchObjects($query); + } - // If not found, try with 'organisation' field. - if (empty($relatedContacts) === true) { - $query['organisation'] = $organizationUuid; - unset($query['organization']); - - $this->logger->info( - '[FLOW] Retrying search with organisation field', - [ - 'organizationId' => $organizationUuid, - 'query' => $query, - ] - ); + if (empty($relatedContacts) === false) { + return $relatedContacts; + } - $relatedContacts = $this->objectService->searchObjects($query); - } + // Fallback: fetch contacts by UUID from the org's contactpersonen array. + // This handles the case where contacts were linked via inversedBy (org → contact). + // but the contact's own organisatie field was never set. + $this->logger->info( + '[FLOW] No contacts found by organisatie field, checking org contactpersonen array', + ['organizationId' => $organizationUuid] + ); - if (empty($relatedContacts) === true) { - // Fallback: fetch contacts by UUID from the org's contactpersonen array. - // This handles the case where contacts were linked via inversedBy (org → contact). - // but the contact's own organisatie field was never set. - $this->logger->info( - '[FLOW] No contacts found by organisatie field, checking org contactpersonen array', - ['organizationId' => $organizationUuid] - ); + $relatedContacts = $this->contactsFromOrganisationList(run: $run); + if (empty($relatedContacts) === true) { + $this->logger->info( + '[FLOW] No related contact persons found (including contactpersonen fallback)', + ['organizationId' => $organizationUuid] + ); + } - // Re-fetch the org object without _extend to get raw UUIDs. - try { - $voorzieningenConfig2 = $this->settingsService->getVoorzieningenConfig(); - $orgRegister = ($voorzieningenConfig2['register'] ?? ''); - $orgSchema = ($voorzieningenConfig2['organisatie_schema'] ?? ''); - - $rawOrgObject = $this->objectService->find( - id: $organizationUuid, - register: $orgRegister, - schema: $orgSchema, - _rbac: false, - _multitenancy: false - ); + return $relatedContacts; + }//end findRelatedContacts() - $rawOrgData = []; - if ($rawOrgObject !== null) { - $rawOrgData = $rawOrgObject->getObject(); - } + /** + * The contact objects listed in the organisation's raw contactpersonen array. + * + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return array The contact objects that could be fetched + */ + private function contactsFromOrganisationList(array $run): array { + $organizationUuid = $run['organizationUuid']; + $contacts = []; - $contactUuids = ($rawOrgData['contactpersonen'] ?? []); + // Re-fetch the org object without _extend to get raw UUIDs. + try { + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $rawOrgObject = $this->objectService->find( + id: $organizationUuid, + register: ($voorzieningenConfig['register'] ?? ''), + schema: ($voorzieningenConfig['organisatie_schema'] ?? ''), + _rbac: false, + _multitenancy: false + ); - $this->logger->info( - '[FLOW] Raw org contactpersonen array', - [ - 'organizationId' => $organizationUuid, - 'contactpersonen' => $contactUuids, - 'count' => count($contactUuids), - ] - ); + $rawOrgData = []; + if ($rawOrgObject !== null) { + $rawOrgData = $rawOrgObject->getObject(); + } - if (empty($contactUuids) === false) { - foreach ($contactUuids as $contactUuid) { - if (is_string($contactUuid) === false || empty($contactUuid) === true) { - continue; - } + $contactUuids = ($rawOrgData['contactpersonen'] ?? []); - try { - $contactObj = $this->objectService->find( - id: $contactUuid, - register: $register, - schema: $contactSchema, - _rbac: false, - _multitenancy: false - ); - if ($contactObj !== null) { - $relatedContacts[] = $contactObj; - } - } catch (\Exception $fetchEx) { - $this->logger->warning( - '[FLOW] Could not fetch contact from contactpersonen array', - [ - 'organizationId' => $organizationUuid, - 'contactUuid' => $contactUuid, - 'exception' => $fetchEx->getMessage(), - ] - ); - }//end try - }//end foreach - }//end if - } catch (\Exception $orgEx) { - $this->logger->warning( - '[FLOW] Could not re-fetch org for contactpersonen fallback', - [ - 'organizationId' => $organizationUuid, - 'exception' => $orgEx->getMessage(), - ] - ); - }//end try + $this->logger->info( + '[FLOW] Raw org contactpersonen array', + [ + 'organizationId' => $organizationUuid, + 'contactpersonen' => $contactUuids, + 'count' => count($contactUuids), + ] + ); - if (empty($relatedContacts) === true) { - $this->logger->info( - '[FLOW] No related contact persons found (including contactpersonen fallback)', - ['organizationId' => $organizationUuid] - ); - return; + foreach ($contactUuids as $contactUuid) { + if (is_string($contactUuid) === false || empty($contactUuid) === true) { + continue; } - }//end if - $this->logger->info( - '👥 PROCESSING RELATED CONTACT PERSONS', + $contactObj = $this->fetchListedContact(contactUuid: $contactUuid, run: $run); + if ($contactObj !== null) { + $contacts[] = $contactObj; + } + } + } catch (\Exception $orgEx) { + $this->logger->warning( + '[FLOW] Could not re-fetch org for contactpersonen fallback', [ - 'app' => 'stackiq', 'organizationId' => $organizationUuid, - 'contactCount' => count($relatedContacts), + 'exception' => $orgEx->getMessage(), ] ); + }//end try - foreach ($relatedContacts as $contactObject) { - try { - $contactUuid = $contactObject->getUuid(); - $contactData = $contactObject->getObject(); + return $contacts; + }//end contactsFromOrganisationList() - // Check if contact data is complete (has email). - $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); - if (empty($email) === true && empty($contactUuid) === false) { - // Re-fetch the full contact object if email is missing. - $this->logger->info( - '[FLOW] Contact data incomplete, re-fetching full object', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactUuid, - ] - ); + /** + * Fetch one contact listed in an organisation's contactpersonen array. + * + * @param string $contactUuid The contact uuid. + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return object|null The contact object, or null when it cannot be fetched + */ + private function fetchListedContact(string $contactUuid, array $run): ?object { + try { + return $this->objectService->find( + id: $contactUuid, + register: $run['register'], + schema: $run['contactSchema'], + _rbac: false, + _multitenancy: false + ); + } catch (\Exception $fetchEx) { + $this->logger->warning( + '[FLOW] Could not fetch contact from contactpersonen array', + [ + 'organizationId' => $run['organizationUuid'], + 'contactUuid' => $contactUuid, + 'exception' => $fetchEx->getMessage(), + ] + ); + return null; + }//end try + }//end fetchListedContact() - try { - $fullContactObject = $this->objectService->find( - id: $contactUuid, - register: $register, - schema: $contactSchema, - _rbac: false, - _multitenancy: false - ); - if ($fullContactObject !== null) { - $contactObject = $fullContactObject; - $contactData = $contactObject->getObject(); - $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); - } - } catch (\Exception $e) { - $this->logger->warning( - '[FLOW] Failed to re-fetch contact object', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactUuid, - 'exception' => $e->getMessage(), - ] - ); - }//end try - }//end if + /** + * Process one related contact: complete it, give it its organisation, then sync it. + * + * @param object $contactObject The contact object. + * @param array $run The sync run: `organizationUuid`, `register`, `contactSchema` and, + * held by reference, `stats`. + * + * @return void + */ + private function processRelatedContact(object $contactObject, array $run): void { + $organizationUuid = $run['organizationUuid']; + try { + $contactUuid = $contactObject->getUuid(); + $contactObject = $this->completeRelatedContact(contactObject: $contactObject, run: $run); + $contactData = $contactObject->getObject(); + $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); - // Ensure the contact has the organisatie field set before processing. - // (may be missing when contacts were linked via inversedBy from the org side). - if (empty($contactData['organization']) === true) { - $contactData['organization'] = $organizationUuid; - $contactObject->setObject($contactData); - // Published contract instead of OpenRegister's Db layer; see - // ContactpersonenController for the reasoning. - $this->objectService->saveObject( - object: $contactData, - register: $contactObject->getRegister(), - schema: $contactObject->getSchema(), - uuid: $contactObject->getUuid(), - silent: true, - _validation: false - ); - $this->logger->info( - '[FLOW] Set missing organisatie field on related contact', - [ - 'contactId' => $contactUuid, - 'organizationUuid' => $organizationUuid, - ] - ); - } + // Ensure the contact has the organisatie field set before processing. + // (may be missing when contacts were linked via inversedBy from the org side). + if (empty($contactData['organization']) === true) { + $contactData['organization'] = $organizationUuid; + $contactObject->setObject($contactData); + // Published contract instead of OpenRegister's Db layer; see + // ContactpersonenController for the reasoning. + $this->objectService->saveObject( + object: $contactData, + register: $contactObject->getRegister(), + schema: $contactObject->getSchema(), + uuid: $contactObject->getUuid(), + silent: true, + _validation: false + ); + $this->logger->info( + '[FLOW] Set missing organisatie field on related contact', + [ + 'contactId' => $contactUuid, + 'organizationUuid' => $organizationUuid, + ] + ); + }//end if - $contactEmailValue = $email; - if ($contactEmailValue === false || $contactEmailValue === '' || $contactEmailValue === null) { - $contactEmailValue = 'unknown'; - } + if ($email === false || $email === '' || $email === null) { + $email = 'unknown'; + } - $this->logger->info( - '[FLOW] Processing related contact person', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactUuid, - 'contactEmail' => $contactEmailValue, - ] - ); + $this->logger->info( + '[FLOW] Processing related contact person', + [ + 'organizationId' => $organizationUuid, + 'contactId' => $contactUuid, + 'contactEmail' => $email, + ] + ); - // Process the contact person through processSpecificContactPerson. - $contactStats = $this->processSpecificContactPerson(contactObject: $contactObject); + // Process the contact person through processSpecificContactPerson. + $contactStats = $this->processSpecificContactPerson(contactObject: $contactObject); - // Merge stats. - $stats['contactPersonsProcessed'] += ($contactStats['contactPersonsProcessed'] ?? 0); - $stats['usersCreated'] += ($contactStats['usersCreated'] ?? 0); - $stats['usersUpdated'] += ($contactStats['usersUpdated'] ?? 0); - if (empty($contactStats['errors']) === false) { - $stats['errors'] = array_merge($stats['errors'], $contactStats['errors']); - } - } catch (\Exception $e) { - $this->logger->error( - '[FLOW] Failed to process related contact person', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactObject->getUuid(), - 'exception' => $e->getMessage(), - ] - ); - $stats['errors'][] = "Related contact {$contactObject->getUuid()}: " . $e->getMessage(); - }//end try - }//end foreach + // Merge stats. + $run['stats']['contactPersonsProcessed'] += ($contactStats['contactPersonsProcessed'] ?? 0); + $run['stats']['usersCreated'] += ($contactStats['usersCreated'] ?? 0); + $run['stats']['usersUpdated'] += ($contactStats['usersUpdated'] ?? 0); + if (empty($contactStats['errors']) === false) { + $run['stats']['errors'] = array_merge($run['stats']['errors'], $contactStats['errors']); + } } catch (\Exception $e) { $this->logger->error( - '[FLOW] Failed to process related contact persons', + '[FLOW] Failed to process related contact person', [ 'organizationId' => $organizationUuid, + 'contactId' => $contactObject->getUuid(), 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), ] ); - $stats['errors'][] = 'Failed to process related contact persons: ' . $e->getMessage(); + $run['stats']['errors'][] = "Related contact {$contactObject->getUuid()}: " . $e->getMessage(); }//end try + }//end processRelatedContact() - }//end processRelatedContactPersons() + /** + * A related contact with its full data: one without an email is fetched again by uuid. + * + * @param object $contactObject The contact object as found. + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return object The re-fetched contact, or the one given when it is complete or cannot be fetched + */ + private function completeRelatedContact(object $contactObject, array $run): object { + $contactUuid = $contactObject->getUuid(); + $contactData = $contactObject->getObject(); + + // Check if contact data is complete (has email). + $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); + if (empty($email) === false || empty($contactUuid) === true) { + return $contactObject; + } + + // Re-fetch the full contact object if email is missing. + $this->logger->info( + '[FLOW] Contact data incomplete, re-fetching full object', + [ + 'organizationId' => $run['organizationUuid'], + 'contactId' => $contactUuid, + ] + ); + + try { + $fullContactObject = $this->objectService->find( + id: $contactUuid, + register: $run['register'], + schema: $run['contactSchema'], + _rbac: false, + _multitenancy: false + ); + if ($fullContactObject !== null) { + return $fullContactObject; + } + } catch (\Exception $e) { + $this->logger->warning( + '[FLOW] Failed to re-fetch contact object', + [ + 'organizationId' => $run['organizationUuid'], + 'contactId' => $contactUuid, + 'exception' => $e->getMessage(), + ] + ); + }//end try + + return $contactObject; + }//end completeRelatedContact() /** * Create or update a contact person object and its user account. @@ -2543,7 +2653,7 @@ private function createBackupOrganisationEntity(string $organizationUuid): ?obje ]; return $this->ensureOrganisationEntity( organisationObject: $orgObject, - stats: $backupStats + run: ['stats' => &$backupStats, 'sendEmails' => true] ); } } catch (\Exception $backupEx) { @@ -2834,7 +2944,7 @@ public function processSpecificContactPerson($contactObject): array { ]; $organisationEntity = $this->ensureOrganisationEntity( organisationObject: $orgObject, - stats: $backupStats + run: ['stats' => &$backupStats, 'sendEmails' => true] ); } } catch (\Exception $backupEx) { diff --git a/lib/Service/StackiqService.php b/lib/Service/StackiqService.php index 7fa7bcc41..d5de3bae9 100644 --- a/lib/Service/StackiqService.php +++ b/lib/Service/StackiqService.php @@ -23,14 +23,9 @@ 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. @@ -77,30 +72,20 @@ class StackiqService { /** * 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() @@ -123,24 +108,6 @@ private function getObjectService(): ?\OCA\OpenRegister\Contract\ObjectServiceIn } }//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 * @@ -400,31 +367,6 @@ public function processContactpersoon(object $contactPersonObject, bool $isUpdat * @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 * @@ -472,345 +414,6 @@ public function getUserManager(string $username): ?string { 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. * @@ -833,29 +436,6 @@ private function activateContactpersonenForOrganization(string $organizationId): // 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 * @@ -1275,2051 +855,370 @@ public function handleOrganizationDeletion(object $organizationObject): void { }//end handleOrganizationDeletion() /** - * Syncs organization data with OpenRegister + * Activates all users in an organization when the organization becomes active * - * @param object $organizationObject The organization object to sync + * @param string $organizationUuid The organization UUID * - * @return bool True if sync was successful - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md + * @return void */ - public function syncOrganizationWithOpenRegister(object $organizationObject): bool { + private function activateUsersForOrganization(string $organizationUuid): void { 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', + 'StackiqService: Activating users for organization', [ '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; + $objectService = $this->getObjectService(); + if ($objectService === null) { + $this->_logger->error('StackiqService: OpenRegister ObjectService not available'); + return; } - $this->_logger->info( - 'StackiqService: SYNC_STEP_3 - OrganisationService retrieved', - [ - 'serviceClass' => get_class($organisationService), - ] - ); + // Get all contactpersonen for this organization. + $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'contactPerson', logContext: 'contactpersonen'); + if ($ctx === null) { + return; + } - $this->_logger->info( - 'StackiqService: SYNC_STEP_4 - OpenRegister configuration', - [ - 'organizationUuid' => $organizationUuid, - 'organizationName' => $objectData['name'] ?? 'Unknown', - ] - ); + $registerId = $ctx['registerId']; + $contactPersonSchemaId = $ctx['schemaId']; - // 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. + // 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' => 6, - // Voorzieningen register. - 'schema' => 38, - // Contactpersoon schema. + 'register' => $registerId, + 'schema' => $contactPersonSchemaId, + 'organisation' => $organizationUuid, ], ] ); - $softwareCatalogUsers = []; - $adminGroupUsers = $this->getAdminGroupUsernames(); + $userManager = $this->_container->get(\OCP\IUserManager::class); + $activatedCount = 0; - foreach ($contactpersonen as $contactPersonObject) { - $contactData = $contactPersonObject->getObject(); - $contactOrganisation = $contactData['organization'] ?? null; + foreach ($contactpersonen as $contactPerson) { + $contactData = $contactPerson->getObject(); + $username = $contactData['username'] ?? ''; - // 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 (empty($username) === false) { + $user = $userManager->get($username); + if ($user !== null && $user->isEnabled() === false) { + $user->setEnabled(true); + $activatedCount++; - if ($username !== false && in_array($username, $adminGroupUsers) === false) { - $softwareCatalogUsers[] = $username; - $this->_logger->debug( - 'StackiqService: Found Stackiq user', + $this->_logger->info( + 'StackiqService: Activated user for organization', [ - 'organizationUuid' => $organizationUuid, 'username' => $username, - 'contactpersoonId' => $contactPersonObject->getId(), + 'organizationUuid' => $organizationUuid, ] - ); - } - } - }//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 + ); + } + } + } + + $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 check contactpersoon addition to org: ' . $e->getMessage(), + 'StackiqService: Failed to activate users for organization: ' . $e->getMessage(), [ - 'objectId' => $contactPersonObject->getId(), + 'organizationUuid' => $organizationUuid, 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), ] ); - return false; }//end try - }//end shouldAddContactpersoonToOrganization() + }//end activateUsersForOrganization() /** - * Adds a contactpersoon username to the organization's users list + * Deactivates all users in an organization when the organization becomes inactive * - * @param object $contactPersonObject The contactpersoon object + * @param string $organizationUuid The organization UUID * - * @return bool True if the user was successfully added - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md + * @return void */ - public function addContactpersoonToOrganization(object $contactPersonObject): bool { + private function deactivateUsersForOrganization(string $organizationUuid): void { 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; - } + $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 false; + return; } - // Get the organization object. - $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'organization', logContext: 'organization'); + // Get all contactpersonen for this organization. + $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'contactPerson', logContext: 'contactpersonen'); if ($ctx === null) { - return false; + return; } $registerId = $ctx['registerId']; - $organisationSchemaId = $ctx['schemaId']; + $contactPersonSchemaId = $ctx['schemaId']; - try { - $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisationSchemaId); - $organizationData = $organizationObject->getObject(); + // 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, + ], + ] + ); - // Add the username to the organization's users list. - $organizationUsers = $organizationData['users'] ?? []; - if (is_array($organizationUsers) === false) { - $organizationUsers = []; - } + $userManager = $this->_container->get(\OCP\IUserManager::class); + $deactivatedCount = 0; - if (in_array($username, $organizationUsers) === false) { - $organizationUsers[] = $username; - $organizationData['users'] = $organizationUsers; + foreach ($contactpersonen as $contactPerson) { + $contactData = $contactPerson->getObject(); + $username = $contactData['username'] ?? ''; - // Update the organization object. - $updatedOrganization = $objectService->saveObject( - $organizationData, - [], - $registerId, - $organisationSchemaId, - $organizationUuid - ); + if (empty($username) === false) { + $user = $userManager->get($username); + if ($user !== null && $user->isEnabled() === true) { + $user->setEnabled(false); + $deactivatedCount++; - $this->_logger->info( - 'StackiqService: Successfully added contactpersoon to organization', - [ - 'username' => $username, - 'organizationUuid' => $organizationUuid, - 'updatedUsers' => $organizationUsers, - ] - ); - }//end if + $this->_logger->info( + 'StackiqService: Deactivated user for organization', + [ + 'username' => $username, + 'organizationUuid' => $organizationUuid, + ] + ); + } + } + } - $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 + $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 add contact person to organization: ' . $e->getMessage(), + 'StackiqService: Failed to deactivate users for organization: ' . $e->getMessage(), [ - 'objectId' => $contactPersonObject->getId(), + 'organizationUuid' => $organizationUuid, 'exception' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ] ); - return false; }//end try - }//end addContactpersoonToOrganization() + }//end deactivateUsersForOrganization() /** - * Handles ownership assignment for anonymous user registrations + * Checks if a contactpersoon username is in the organization's users list * - * @param object $organizationObject The organization object + * @param object $contactPersonObject The contactpersoon object * - * @return void + * @return bool True if the user should be added to the organization + * @spec openspec/specs/softwarecatalogue-orchestration/spec.md */ - private function handleOwnershipAssignment(object $organizationObject): void { + public function shouldAddContactpersoonToOrganization(object $contactPersonObject): bool { try { - $this->_logger->info( - 'StackiqService: Handling ownership assignment for organization', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - - $objectData = $organizationObject->getObject(); - $organizationUuid = $objectData['id'] ?? $organizationObject->getId(); - $contactpersonen = $objectData['contactpersonen'] ?? []; + $objectData = $contactPersonObject->getObject(); + $username = $objectData['username'] ?? ''; + $organizationUuid = $objectData['organisation'] ?? ''; - if (empty($contactpersonen) === true) { - $this->_logger->info( - 'StackiqService: No contact persons found for ownership assignment', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - return; + if (empty($username) === true || empty($organizationUuid) === true) { + return false; } - // 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; + return false; } - // 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; + // Get the organization object. + $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'organization', logContext: 'organization'); + if ($ctx === null) { + return false; } - // 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; + $registerId = $ctx['registerId']; + $organisationSchemaId = $ctx['schemaId']; - $updatedPrimaryContact = $objectService->saveObject( - $primaryContactData, - [], - $registerId, - $contactPersonSchemaId, - $primaryContactUuid - ); + try { + $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisationSchemaId); + $organizationData = $organizationObject->getObject(); - // 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 + // 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: Successfully assigned ownership for organization', + 'StackiqService: Contactpersoon should be added to organization', [ + 'username' => $username, 'organizationUuid' => $organizationUuid, - 'primaryOwner' => $primaryUsername, - 'organisationEntityUuid' => $organisationEntityUuid, - 'contactPersonCount' => count($contactpersonen), - 'retries' => $retry, + 'currentUsers' => $organizationUsers, ] ); + return true; + } - 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 + 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: Error handling ownership assignment', + 'StackiqService: Failed to check contactpersoon addition to org: ' . $e->getMessage(), [ - 'objectId' => $organizationObject->getId(), - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), + 'objectId' => $contactPersonObject->getId(), + 'exception' => $e->getMessage(), ] ); + return false; }//end try - }//end handleOwnershipAssignment() + }//end shouldAddContactpersoonToOrganization() /** - * 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 + * Adds a contactpersoon username to the organization's users list * - * @param string $organizationUuid The UUID of the organization + * @param object $contactPersonObject The contactpersoon object * - * @return void + * @return bool True if the user was successfully added * @spec openspec/specs/softwarecatalogue-orchestration/spec.md */ - public function syncContactPersonUsernamesWithOrganization(string $organizationUuid): void { - $this->_logger->info( - 'StackiqService: Starting contact person username synchronization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); + public function addContactpersoonToOrganization(object $contactPersonObject): bool { + try { + $objectData = $contactPersonObject->getObject(); + $username = $objectData['username'] ?? ''; + $organizationUuid = $objectData['organisation'] ?? ''; - // Get the ObjectService to find contact persons. - $objectService = $this->getObjectService(); - if ($objectService === null) { - $this->_logger->error('StackiqService: ObjectService not available for username synchronization'); - return; - } + 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 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; + // Get the organization object. + $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'organization', logContext: 'organization'); + if ($ctx === null) { + return false; + } - if ($contactSchemaId === null || $registerId === false) { - $this->_logger->warning( - 'StackiqService: Missing Voorzieningen configuration for contact person sync', - [ - 'organizationUuid' => $organizationUuid, - 'contactSchemaId' => $contactSchemaId, - 'registerId' => $registerId, - ] - ); - return; - } + $registerId = $ctx['registerId']; + $organisationSchemaId = $ctx['schemaId']; - try { - // Find all contact persons that have this organization as their organisatie. - $contactPersons = $objectService->findAll( - [ - 'filters' => [ - 'register' => (string)$registerId, - 'schema' => $contactSchemaId, - 'organization' => $organizationUuid, - ], - ] - ); + try { + $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisationSchemaId); + $organizationData = $organizationObject->getObject(); - $this->_logger->info( - 'StackiqService: Found contact persons for synchronization', - [ - 'organizationUuid' => $organizationUuid, - 'contactPersonCount' => count($contactPersons), - ] - ); + // 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 + ); - // 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', + 'StackiqService: Successfully added contactpersoon to organization', [ - 'username' => $email, - 'contactPersonId' => $contactPerson->getId(), + 'username' => $username, + 'organizationUuid' => $organizationUuid, + 'updatedUsers' => $organizationUsers, ] ); - } - } + }//end if - // Get the organization entity. - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationMapper === null) { - $this->_logger->error( - 'StackiqService: OpenRegister OrganisationMapper not available for synchronization', + $this->_logger->debug( + 'StackiqService: Contactpersoon already in organization', [ + 'username' => $username, 'organizationUuid' => $organizationUuid, ] ); - return; - } - - $organisation = $organisationMapper->findByUuid($organizationUuid); - - if ($organisation === null) { + return true; + // Already there, consider it successful. + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { $this->_logger->error( - 'StackiqService: Organization entity not found for synchronization', + 'StackiqService: Organization not found for contactpersoon', [ + 'username' => $username, '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', - ] - ); + return false; + }//end try } catch (\Exception $e) { $this->_logger->error( - 'StackiqService: Error synchronizing contact person usernames', + 'StackiqService: Failed to add contact person to organization: ' . $e->getMessage(), [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), + 'objectId' => $contactPersonObject->getId(), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ] ); + return false; }//end try - }//end syncContactPersonUsernamesWithOrganization() + }//end addContactpersoonToOrganization() /** * Ensures a contact person's username is in their organization's users array. @@ -3442,161 +1341,6 @@ private function ensureContactPersonInOrganization(object $contactPersonObject): }//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. diff --git a/tests/OrganizationSyncTest.php b/tests/OrganizationSyncTest.php deleted file mode 100644 index 904a01dae..000000000 --- a/tests/OrganizationSyncTest.php +++ /dev/null @@ -1,272 +0,0 @@ - - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @version 1.0.0 - * @link https://github.com/ConductionNL/stackiq - */ - -declare(strict_types=1); - -namespace OCA\Stackiq\Tests; - -use OCA\OpenRegister\Contract\ObjectServiceInterface; -use OCA\Stackiq\Service\SettingsService; -use OCA\Stackiq\Service\StackiqService; -use OCP\AppFramework\Db\Entity; -use OCP\IGroupManager; -use OCP\IUserManager; -use PHPUnit\Framework\TestCase; - -/** - * Test class for organization synchronization functionality - * - * @category Test - * @package OCA\Stackiq\Tests - * @author Conduction b.v. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @version 1.0.0 - * @link https://github.com/ConductionNL/stackiq - */ -class OrganizationSyncTest extends TestCase { - /** - * Test organization creation and OpenRegister synchronization - * - * @return void - */ - public function testOrganizationCreationSync(): void { - // Mock dependencies - $objectService = $this->createMock(ObjectServiceInterface::class); - $settingsService = $this->createMock(SettingsService::class); - $userManager = $this->createMock(IUserManager::class); - $groupManager = $this->createMock(IGroupManager::class); - - // Configure mocks - $settingsService->method('getRegisterIdForObjectType') - ->with('organization') - ->willReturn(1); - - $settingsService->method('getSchemaIdForObjectType') - ->with('organization') - ->willReturn(37); - - // Test organization data - $organizationData = [ - 'name' => 'Test Organization', - 'type' => 'Municipality', - 'website' => 'https://test.org', - 'beoordeling' => 'actief', - 'id' => 'test-org-uuid-123' - ]; - - // Mock organization object - $organizationObject = $this->createMock(Entity::class); - $organizationObject->method('getId') - ->willReturn('test-org-uuid-123'); - $organizationObject->method('getObject') - ->willReturn($organizationData); - - // Mock OpenRegister response - $openRegisterObject = $this->createMock(Entity::class); - $openRegisterObject->method('getId') - ->willReturn('test-org-uuid-123'); - - $objectService->method('find') - ->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('Not found')); - - $objectService->method('saveObject') - ->willReturn($openRegisterObject); - - // Create service instance - $service = new StackiqService( - $this->createMock(\OCA\Stackiq\Service\Stackiq\OrganizationHandler::class), - $this->createMock(\OCA\Stackiq\Service\Stackiq\ContactPersonHandler::class), - $this->createMock(\OCA\Stackiq\Service\Stackiq\GroupHandler::class), - $this->createMock(\OCA\Stackiq\Service\Stackiq\HierarchyHandler::class), - $this->createMock(\OCA\Stackiq\Service\SymfonyEmailService::class), - $this->createMock(\Psr\Log\LoggerInterface::class), - $this->createMock(\Psr\Container\ContainerInterface::class), - $this->createMock(\OCP\App\IAppManager::class), - _userSession: $this->createMock(IUserSession::class), - _userManager: $this->createMock(IUserManager::class), - _groupManager: $this->createMock(IGroupManager::class), - ); - - // Test synchronization - $result = $service->syncOrganizationWithOpenRegister($organizationObject); - - // Assertions - $this->assertTrue($result, 'Organization sync should succeed'); - } - - /** - * Test organization status mapping - * - * @return void - */ - public function testOrganizationStatusMapping(): void { - // Test data mapping - $testCases = [ - [ - 'input' => ['beoordeling' => 'actief'], - 'expected' => 'active' - ], - [ - 'input' => ['beoordeling' => 'inactief'], - 'expected' => 'inactive' - ], - [ - 'input' => ['beoordeling' => 'deactief'], - 'expected' => 'inactive' - ], - [ - 'input' => ['beoordeling' => 'unknown'], - 'expected' => 'Draft' - ] - ]; - - foreach ($testCases as $testCase) { - $mappedData = $this->mapOrganizationDataForOpenRegister($testCase['input']); - $this->assertEquals($testCase['expected'], $mappedData['status'], - 'Status mapping should work correctly for: ' . $testCase['input']['beoordeling']); - } - } - - /** - * Test contactpersoon organization membership - * - * @return void - */ - public function testContactpersoonOrganizationMembership(): void { - // Mock dependencies - $objectService = $this->createMock(ObjectServiceInterface::class); - $settingsService = $this->createMock(SettingsService::class); - - // Configure mocks - $settingsService->method('getVoorzieningenRegisterId') - ->willReturn(1); - - $settingsService->method('getSchemaIdForObjectType') - ->with('organization') - ->willReturn(37); - - // Test contactpersoon data - $contactPersonData = [ - 'username' => 'test.user@example.com', - 'organisation' => 'test-org-uuid-123' - ]; - - // Mock contactpersoon object - $contactPersonObject = $this->createMock(Entity::class); - $contactPersonObject->method('getId') - ->willReturn('test-contact-uuid-456'); - $contactPersonObject->method('getObject') - ->willReturn($contactPersonData); - - // Mock organization data - $organizationData = [ - 'users' => ['existing.user@example.com'] - ]; - - // Mock organization object - $organizationObject = $this->createMock(Entity::class); - $organizationObject->method('getObject') - ->willReturn($organizationData); - - $objectService->method('find') - ->willReturn($organizationObject); - - // Test should add to organization - $result = $this->shouldAddContactpersoonToOrganization($contactPersonObject); - $this->assertTrue($result, 'Contactpersoon should be added to organization'); - } - - /** - * Test user status management based on organization status - * - * @return void - */ - public function testUserStatusManagement(): void { - // Mock user manager - $userManager = $this->createMock(IUserManager::class); - - // Mock user - $user = $this->createMock(\OCP\IUser::class); - $user->method('isEnabled') - ->willReturn(false); - $user->method('setEnabled') - ->willReturnSelf(); - - $userManager->method('get') - ->willReturn($user); - - // Test user activation - $this->assertFalse($user->isEnabled(), 'User should be inactive initially'); - - // Simulate activation - $user->setEnabled(true); - $this->assertTrue($user->isEnabled(), 'User should be active after activation'); - } - - /** - * Helper method to map organization data for OpenRegister - * - * @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; - } - - return $mappedData; - } - - /** - * Helper method to check if contactpersoon should be added to organization - * - * @param object $contactPersonObject The contactpersoon object - * - * @return bool True if the user should be added to the organization - */ - private function shouldAddContactpersoonToOrganization(object $contactPersonObject): bool { - $objectData = $contactPersonObject->getObject(); - $username = $objectData['username'] ?? ''; - $organizationUuid = $objectData['organisation'] ?? ''; - - if (empty($username) || empty($organizationUuid)) { - return false; - } - - // Mock organization data - $organizationData = [ - 'users' => ['existing.user@example.com'] - ]; - - // Check if the username is already in the organization's users - $organizationUsers = $organizationData['users'] ?? []; - - return is_array($organizationUsers) && !in_array($username, $organizationUsers); - } -} diff --git a/tests/Stubs/Db/OrganisationMapper.php b/tests/Stubs/Db/OrganisationMapper.php index 140cdefb0..e76182296 100644 --- a/tests/Stubs/Db/OrganisationMapper.php +++ b/tests/Stubs/Db/OrganisationMapper.php @@ -31,6 +31,15 @@ abstract class OrganisationMapper { */ abstract public function findByUuid(string $uuid): Organisation; + /** + * Find an organisation by slug. + * + * @param string $slug The organisation slug. + * + * @return Organisation + */ + abstract public function findBySlug(string $slug): Organisation; + /** * Persist an organisation entity. * diff --git a/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php b/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php new file mode 100644 index 000000000..f994db1c4 --- /dev/null +++ b/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php @@ -0,0 +1,333 @@ + + * @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/aangeboden-gebruik-api/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Service\OrganisationService; +use OCA\Stackiq\Service\AangebodenGebruikService; +use OCA\Stackiq\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\AbstractLogger; + +/** + * Golden cases for the koppelingen and gebruik lookup by uuid. + * + * @covers \OCA\Stackiq\Service\AangebodenGebruikService + * + * @spec openspec/specs/aangeboden-gebruik-api/spec.md + */ +class AangebodenGebruikKoppelingenGoldenTest extends TestCase { + + /** + * The organisation of the signed-in user. + */ + private const ACTING_ORG = 'org-acting'; + + /** + * Everything the doubles saw, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Per-case behaviour of the doubles. + * + * @var array + */ + private array $plan = []; + + /** + * The golden file. + * + * @return string + */ + private static function golden(): string { + return __DIR__ . '/golden/aangebodenGebruikKoppelingen.json'; + + }//end golden() + + /** + * The cases: uuid, options, ambtenaar flag and the behaviour of the doubles. + * + * @return array}> + */ + public static function cases(): array { + $module = ['owner' => self::ACTING_ORG, 'schema' => 'schema-module']; + $org = ['owner' => self::ACTING_ORG, 'schema' => 'schema-organisatie']; + return [ + 'empty uuid' => ['', [], true, []], + 'missing koppeling schema' => ['uuid-1', [], true, ['config' => ['register' => 'reg-1', 'gebruik_schema' => 'schema-gebruik']]], + 'openregister not installed' => ['uuid-1', [], true, ['noOpenRegister' => true]], + 'ambtenaar module with filter' => ['uuid-mod', ['organisation' => 'org-x', '_limit' => 5], true, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'ambtenaar module without filter' => ['uuid-mod', ['_page' => 2], true, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'ambtenaar organisation with filter' => ['uuid-org', ['organisation' => 'org-x'], true, ['found' => ['owner' => 'org-other', 'schema' => 'schema-organisatie']]], + 'ambtenaar organisation default id' => ['uuid-org', [], true, ['found' => ['owner' => 'org-other', 'schema' => '15'], 'config' => ['register' => 'reg-1', 'gebruik_schema' => 'schema-gebruik', 'koppeling_schema' => 'schema-koppeling']]], + 'ambtenaar find throws' => ['uuid-mod', [], true, ['findThrows' => true]], + 'ambtenaar find returns null' => ['uuid-mod', [], true, ['found' => null]], + 'ambtenaar without a session' => ['uuid-mod', [], true, ['noUser' => true, 'found' => $module]], + 'owner module, forged filter' => ['uuid-mod', ['organisation' => 'org-forged'], false, ['found' => $module]], + 'owner organisation' => ['uuid-org', [], false, ['found' => $org]], + 'not the owner' => ['uuid-mod', [], false, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'no owner on the object' => ['uuid-mod', [], false, ['found' => ['owner' => null, 'schema' => 'schema-module']]], + 'no session' => ['uuid-mod', [], false, ['noUser' => true, 'found' => $module]], + 'no active organisation' => ['uuid-mod', [], false, ['noActiveOrg' => true, 'found' => $module]], + 'organisation lookup throws' => ['uuid-mod', [], false, ['orgThrows' => true, 'found' => $module]], + 'ownership lookup throws' => ['uuid-mod', [], false, ['findThrows' => true]], + 'ownership object missing' => ['uuid-mod', [], false, ['found' => null]], + 'search throws' => ['uuid-mod', [], true, ['found' => $module, 'searchThrows' => true]], + 'not the owner, filter names the owner' => ['uuid-mod', ['organisation' => 'org-other'], false, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'owner in another case' => ['uuid-mod', [], false, ['found' => ['owner' => 'ORG-ACTING', 'schema' => 'schema-module']]], + 'owner with trailing space' => ['uuid-mod', [], false, ['found' => ['owner' => self::ACTING_ORG . ' ', 'schema' => 'schema-module']]], + 'forged @self and ambtenaar options' => ['uuid-mod', ['@self' => ['organisation' => self::ACTING_ORG, 'register' => 'reg-forged'], 'isAmbtenaar' => true, 'organisation' => 'org-other'], false, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'owner, forged @self in options' => ['uuid-mod', ['@self' => ['register' => 'reg-forged', 'schema' => 'schema-forged']], false, ['found' => $module]], + ]; + + }//end cases() + + /** + * Each case matches its recording. + * + * @param string $uuid The uuid looked up. + * @param array $options The query options. + * @param bool $isAmbtenaar Whether the caller is an ambtenaar. + * @param array $plan The behaviour of the doubles. + * + * @return void + * + * @dataProvider cases + */ + public function testLookupMatchesTheRecording(string $uuid, array $options, bool $isAmbtenaar, array $plan): void { + $this->plan = $plan; + $this->trace = []; + try { + $result = $this->service()->getKoppelingenGebruikByUuid(uuid: $uuid, options: $options, isAmbtenaar: $isAmbtenaar); + } catch (\Throwable $e) { + $result = ['escaped' => get_class($e), 'message' => $e->getMessage()]; + } + + $key = json_encode([$uuid, $options, $isAmbtenaar, $plan]); + $this->check(key: $key, value: ['result' => $result, 'calls' => $this->trace]); + + }//end testLookupMatchesTheRecording() + + /** + * The service with recording doubles. + * + * @return AangebodenGebruikService + */ + private function service(): AangebodenGebruikService { + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getInstalledApps')->willReturn(isset($this->plan['noOpenRegister']) === true ? ['files'] : ['openregister']); + + $objectService = $this->objectService(); + $organisationService = $this->organisationService(); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($objectService, $organisationService) { + $this->trace[] = ['container.get', $id]; + if ($id === 'OCA\OpenRegister\Service\OrganisationService') { + if (isset($this->plan['orgThrows']) === true) { + throw new \RuntimeException('organisation service down'); + } + + return $organisationService; + } + + return $objectService; + } + ); + + $settings = $this->createMock(SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturnCallback( + function (): array { + $this->trace[] = ['settings.getVoorzieningenConfig']; + return $this->plan['config'] ?? [ + 'register' => 'reg-1', + 'gebruik_schema' => 'schema-gebruik', + 'koppeling_schema' => 'schema-koppeling', + 'organisatie_schema' => 'schema-organisatie', + ]; + } + ); + + $userSession = $this->createMock(IUserSession::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('acting-user'); + $userSession->method('getUser')->willReturn(isset($this->plan['noUser']) === true ? null : $user); + + return new AangebodenGebruikService( + config: $this->createMock(IAppConfig::class), + appManager: $appManager, + container: $container, + logger: $this->logger(), + settingsService: $settings, + userSession: $userSession + ); + + }//end service() + + /** + * An organisation service whose active organisation is the acting one. + * + * @return OrganisationService + */ + private function organisationService(): OrganisationService { + $organisation = new Organisation(); + $organisation->setUuid(self::ACTING_ORG); + $service = $this->createMock(OrganisationService::class); + $service->method('getActiveOrganisation')->willReturnCallback( + function () use ($organisation): ?Organisation { + $this->trace[] = ['organisations.getActiveOrganisation']; + return isset($this->plan['noActiveOrg']) === true ? null : $organisation; + } + ); + + return $service; + + }//end organisationService() + + /** + * An object service that records the lookups and the search. + * + * @return ObjectServiceInterface + */ + private function objectService(): ObjectServiceInterface { + $objectService = $this->createMock(ObjectServiceInterface::class); + $objectService->method('find')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.find', $args[0], $args[5] ?? null, $args[6] ?? null]; + if (isset($this->plan['findThrows']) === true) { + throw new \RuntimeException('find failed'); + } + + $found = $this->plan['found'] ?? null; + if ($found === null) { + return null; + } + + $entity = $this->createMock(ObjectEntity::class); + $entity->method('getObject')->willReturn(['@self' => ['organisation' => $found['owner'], 'schema' => $found['schema']]]); + return $entity; + } + ); + $objectService->method('buildSearchQuery')->willReturnCallback( + function (array $requestParams): array { + $this->trace[] = ['objects.buildSearchQuery', $requestParams]; + // Like OpenRegister, a caller's @self filters are carried into the query. + return ['@self' => ['seed' => true] + ($requestParams['@self'] ?? []), '_limit' => $requestParams['_limit'] ?? 20]; + } + ); + $objectService->method('searchObjectsPaginated')->willReturnCallback( + function (...$args): array { + $this->trace[] = [ + 'objects.searchObjectsPaginated', + [ + 'query' => $args[0], + '_rbac' => $args[1], + '_multitenancy' => $args[2], + 'deleted' => $args[3], + 'uses' => $args[5] ?? null, + ], + ]; + if (isset($this->plan['searchThrows']) === true) { + throw new \RuntimeException('search failed'); + } + + return ['results' => [['id' => 'g-1']], 'total' => 1, 'page' => 1, 'pages' => 1]; + } + ); + + return $objectService; + + }//end objectService() + + /** + * A logger that records every line, without stack traces. + * + * @return AbstractLogger + */ + private function logger(): AbstractLogger { + $trace = &$this->trace; + return new class($trace) extends AbstractLogger { + /** + * Build the logger. + * + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * Record one line. + * + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context = []): void { + unset($context['trace']); + $this->trace[] = ['log.' . $level, (string) $message, $context]; + } + }; + + }//end logger() + + /** + * Compare a case with the golden file, or record it. + * + * @param string $key The case. + * @param array $value What the case observed. + * + * @return void + */ + private function check(string $key, array $value): void { + $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); + $file = self::golden(); + $saved = []; + if (file_exists($file) === true) { + $saved = json_decode((string) file_get_contents($file), true); + } + + if (getenv('RECORD_GOLDEN') !== false) { + $saved[$key] = $json; + file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); + $this->addToAssertionCount(count: 1); + return; + } + + $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); + $this->assertSame(expected: $saved[$key], actual: $json); + + }//end check() + +}//end class diff --git a/tests/Unit/Service/ContactpersoonBackupEntityTest.php b/tests/Unit/Service/ContactpersoonBackupEntityTest.php new file mode 100644 index 000000000..f985d10b9 --- /dev/null +++ b/tests/Unit/Service/ContactpersoonBackupEntityTest.php @@ -0,0 +1,174 @@ + + * @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/method-decomposition/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\Stackiq\Service\ContactpersoonService; +use OCA\Stackiq\Service\OrganizationSyncService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; +use OCA\Stackiq\Service\Stackiq\GroupHandler; +use OCA\Stackiq\Service\Stackiq\HierarchyHandler; +use OCP\App\IAppManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; +use OCP\IUserManager; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; + +/** + * A contact person of an active organisation without an entity triggers the backup creation. + * + * @covers \OCA\Stackiq\Service\ContactpersoonService + * + * @spec openspec/specs/method-decomposition/spec.md + */ +class ContactpersoonBackupEntityTest extends TestCase { + + /** + * The organisation object the backup entity is created from. + * + * @return void + */ + public function testTheBackupEntityIsCreatedFromTheOrganisationObject(): void { + $organisation = $this->stored(id: 'org-1', data: ['status' => 'Actief']); + $calls = []; + + $sync = $this->createMock(OrganizationSyncService::class); + $sync->method('ensureOrganisationEntityPublic')->willReturnCallback( + function (object $organisationObject, array &$stats, bool $sendEmails = true) use (&$calls) { + $calls[] = [$organisationObject, $sendEmails]; + return null; + } + ); + + $this->service(organisation: $organisation, sync: $sync)->processContactpersoon( + $this->stored(id: 'contact-backup-1', data: ['email' => 'new@gemeente.nl', 'organisation' => 'org-1']) + ); + + self::assertCount(1, $calls); + self::assertSame($organisation, $calls[0][0]); + self::assertTrue($calls[0][1], 'the backup creation keeps the default: registration mails are sent'); + }//end testTheBackupEntityIsCreatedFromTheOrganisationObject() + + /** + * An inactive organisation gets no backup entity. + * + * @return void + */ + public function testAnInactiveOrganisationGetsNoBackupEntity(): void { + $sync = $this->createMock(OrganizationSyncService::class); + $sync->expects(self::never())->method('ensureOrganisationEntityPublic'); + + $this->service(organisation: $this->stored(id: 'org-1', data: ['status' => 'inactief']), sync: $sync)->processContactpersoon( + $this->stored(id: 'contact-backup-2', data: ['email' => 'new@gemeente.nl', 'organisation' => 'org-1']) + ); + }//end testAnInactiveOrganisationGetsNoBackupEntity() + + /** + * The service over a container whose organisation has no OpenRegister entity yet. + * + * @param object $organisation The stored organisation object. + * @param OrganizationSyncService $sync The sync service double. + * + * @return ContactpersoonService + */ + private function service(object $organisation, OrganizationSyncService $sync): ContactpersoonService { + $users = $this->createMock(IUserManager::class); + $users->method('get')->willReturn(null); + + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findByUuid')->willThrowException(new DoesNotExistException('missing')); + + $objects = new class($organisation) { + /** + * @param object $organisation The stored organisation. + */ + public function __construct(private object $organisation) { + } + + /** + * @param mixed ...$args The lookup. + * + * @return object + */ + public function find(...$args): object { + return $this->organisation; + } + }; + + $settings = $this->createMock(SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturn([]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + fn (string $id) => [ + 'OCP\IUserManager' => $users, + 'OCA\OpenRegister\Db\OrganisationMapper' => $mapper, + 'OCA\OpenRegister\Service\ObjectService' => $objects, + 'OCA\Stackiq\Service\SettingsService' => $settings, + 'OCA\Stackiq\Service\OrganizationSyncService' => $sync, + ][$id] ?? null + ); + + return new ContactpersoonService( + contactPersonHandler: $this->createMock(ContactPersonHandler::class), + groupHandler: $this->createMock(GroupHandler::class), + hierarchyHandler: $this->createMock(HierarchyHandler::class), + logger: new NullLogger(), + container: $container, + appManager: $this->createMock(IAppManager::class), + config: $this->createMock(IAppConfig::class), + settingsService: $settings + ); + }//end service() + + /** + * A stored object double. + * + * @param string $id The id. + * @param array $data The data. + * + * @return object + */ + private function stored(string $id, array $data): object { + return new class($id, $data) { + /** + * @param string $id The id. + * @param array $data The data. + */ + public function __construct(private string $id, private array $data) { + } + + /** + * @return string + */ + public function getId(): string { + return $this->id; + } + + /** + * @return array + */ + public function getObject(): array { + return $this->data; + } + }; + }//end stored() +}//end class diff --git a/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php new file mode 100644 index 000000000..952aebe9a --- /dev/null +++ b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php @@ -0,0 +1,701 @@ + + * @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/organization-sync/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\Stackiq\Service\OrganisatieService; +use OCA\Stackiq\Service\OrganizationSyncService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; +use OCA\Stackiq\Service\SymfonyEmailService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUser; +use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; + +/** + * Golden cases for the organisation entity sync. + * + * @covers \OCA\Stackiq\Service\OrganizationSyncService + * + * @spec openspec/specs/organization-sync/spec.md + */ +class OrganizationSyncEntityGoldenTest extends TestCase { + + /** + * Everything the doubles saw, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Per-case behaviour of the doubles. + * + * @var array + */ + private array $plan = []; + + /** + * The golden file. + * + * @return string + */ + private static function golden(): string { + return __DIR__ . '/golden/organizationSyncEntity.json'; + + }//end golden() + + /** + * A contact or organisation object double that records its writes. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * + * @return ObjectEntityInterface + */ + private function entity(array $object, string $uuid): ObjectEntityInterface { + $trace = &$this->trace; + return new class($object, $uuid, $trace) implements ObjectEntityInterface { + /** + * The owner set on the entity. + * + * @var string|null + */ + private ?string $owner = null; + + /** + * The organisation set on the entity. + * + * @var string|null + */ + private ?string $organisation = 'org-of-entity'; + + /** + * Build the double. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * @param array $trace The shared trace. + */ + public function __construct(private array $object, private string $uuid, private array &$trace) { + } + + /** + * @return mixed + */ + public function getId() { + return 7; + } + + /** + * @return string|null + */ + public function getUuid(): ?string { + return $this->uuid; + } + + /** + * @return array + */ + public function getObject(): array { + return $this->object; + } + + /** + * @return string|null + */ + public function getRegister(): ?string { + return 'reg-entity'; + } + + /** + * @return string|null + */ + public function getSchema(): ?string { + return 'schema-entity'; + } + + /** + * @return string|null + */ + public function getOrganisation(): ?string { + return $this->organisation; + } + + /** + * @return string|null + */ + public function getOwner(): ?string { + return $this->owner; + } + + /** + * @param mixed $object The data. + * + * @return void + */ + public function setObject($object=null) { + $this->trace[] = ['entity.setObject', $this->uuid, $object]; + $this->object = $object; + } + + /** + * @param string|null $owner The owner. + * + * @return void + */ + public function setOwner(?string $owner): void { + $this->trace[] = ['entity.setOwner', $this->uuid, $owner]; + $this->owner = $owner; + } + + /** + * @param string|null $organisation The organisation. + * + * @return void + */ + public function setOrganisation(?string $organisation): void { + $this->trace[] = ['entity.setOrganisation', $this->uuid, $organisation]; + $this->organisation = $organisation; + } + + /** + * @return mixed + */ + public function jsonSerialize(): mixed { + return $this->object; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + $this->trace[] = ['entity.' . $name, $this->uuid, $args]; + return null; + } + }; + + }//end entity() + + /** + * Arguments by parameter name, leaving out the ones left at their default. + * + * @param string $method The ObjectServiceInterface method. + * @param array $args The positional arguments the double received. + * + * @return array + */ + private static function named(string $method, array $args): array { + $named = []; + foreach ((new \ReflectionMethod(ObjectServiceInterface::class, $method))->getParameters() as $i => $param) { + if (array_key_exists($i, $args) === false) { + continue; + } + + if ($param->isDefaultValueAvailable() === true && $param->getDefaultValue() === $args[$i]) { + continue; + } + + $value = $args[$i]; + if ($value instanceof ObjectEntityInterface) { + $value = 'entity:' . $value->getUuid(); + } + + $named[$param->getName()] = $value; + } + + return $named; + + }//end named() + + /** + * Build the service with recording doubles driven by $this->plan. + * + * @return OrganizationSyncService + */ + private function service(): OrganizationSyncService { + $trace = &$this->trace; + $logger = new class($trace) extends AbstractLogger { + /** + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context=[]): void { + foreach (['trace', 'file', 'line'] as $volatile) { + if (array_key_exists($volatile, $context) === true) { + $context[$volatile] = '(' . $volatile . ')'; + } + } + + $this->trace[] = ['log.' . $level, (string) $message, $context]; + } + }; + + $objects = $this->createMock(originalClassName: ObjectServiceInterface::class); + $objects->method('find')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.find', self::named(method: 'find', args: $args)]; + $answer = array_shift($this->plan['find']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $objects->method('saveObject')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.saveObject', self::named(method: 'saveObject', args: $args)]; + $answer = array_shift($this->plan['save']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return ($answer ?? $this->entity(object: ['saved' => true], uuid: 'saved-uuid')); + } + ); + + $mapper = $this->createMock(originalClassName: OrganisationMapper::class); + $mapper->method('findBySlug')->willReturnCallback( + function (string $slug) { + $this->trace[] = ['mapper.findBySlug', $slug]; + $answer = array_shift($this->plan['slug']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $mapper->method('save')->willReturnCallback( + function ($entity) { + $this->trace[] = ['mapper.save', $entity->getUuid(), $entity->getActive()]; + return $entity; + } + ); + $mapper->method('findByUuid')->willReturnCallback( + function (string $uuid) { + $this->trace[] = ['mapper.findByUuid', $uuid]; + $answer = array_shift($this->plan['org']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + $handler = $this->createMock(originalClassName: ContactPersonHandler::class); + $handler->method('isFirstContactForOrganization')->willReturnCallback( + function ($contact, $data) { + $this->trace[] = ['handler.isFirst', $contact->getUuid(), $data]; + return $this->plan['first'] ?? false; + } + ); + $handler->method('createUserAccount')->willReturnCallback( + function ($contact, $isFirst) { + $this->trace[] = ['handler.createUserAccount', $contact->getUuid(), $isFirst]; + return ($this->plan['user'] ?? null); + } + ); + $handler->method('addUserToOrganizationEntity')->willReturnCallback( + function (...$args) { + $args[0] = $args[0]->getUuid(); + $this->trace[] = ['handler.addUserToOrganizationEntity', $args]; + return true; + } + ); + + $settings = $this->createMock(originalClassName: SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturnCallback( + function () { + $this->trace[] = ['settings.getVoorzieningenConfig']; + $answer = ($this->plan['config'] ?? ['register' => 'reg-v', 'organisatie_schema' => 'org-schema']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + // Built without the constructor: it type-hints IDBConnection, whose + // mock needs Doctrine, and the methods under test never touch it. + $reflection = new \ReflectionClass(OrganizationSyncService::class); + $service = $reflection->newInstanceWithoutConstructor(); + $properties = [ + 'logger' => $logger, + 'settingsService' => $settings, + 'contactpersonHandler' => $handler, + 'objectService' => $objects, + 'organisationMapper' => $mapper, + 'organisationService' => $this->organisationService(), + 'emailService' => $this->emailService(), + ]; + foreach ($properties as $name => $value) { + $reflection->getProperty($name)->setValue($service, $value); + } + + return $service; + + }//end service() + + /** + * An organisation service double that records the organisations it creates. + * + * @return OrganisatieService + */ + private function organisationService(): OrganisatieService { + $service = $this->createMock(originalClassName: OrganisatieService::class); + $service->method('createOrganisationInOpenRegister')->willReturnCallback( + function (array $data) { + $this->trace[] = ['organisations.create', $data]; + return array_key_exists('created', $this->plan) === true ? $this->plan['created'] : $this->orgEntity(uuid: 'new-entity', active: true); + } + ); + return $service; + + }//end organisationService() + + /** + * An organisation entity double. + * + * @param mixed $active What getActive() answers, or a Throwable it throws. + * + * @return Organisation + */ + private static function organisation(mixed $active): Organisation { + return new class($active) extends Organisation { + /** + * @param mixed $active The answer. + */ + public function __construct(private mixed $active) { + } + + /** + * @return mixed + */ + public function getActive(): mixed { + if ($this->active instanceof \Throwable) { + throw $this->active; + } + + return $this->active; + } + + /** + * @return string + */ + public function getName(): string { + return 'Org'; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + return null; + } + }; + + }//end organisation() + + /** + * A user double. + * + * @param string $uid The uid. + * + * @return IUser + */ + private function user(string $uid): IUser { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn($uid); + return $user; + + }//end user() + + /** + * An e-mail service double that records the mails and answers per plan. + * + * @return SymfonyEmailService + */ + private function emailService(): SymfonyEmailService { + $mail = $this->createMock(originalClassName: SymfonyEmailService::class); + foreach (['sendOrganizationRegistrationEmail', 'sendOrganizationActivationEmail'] as $method) { + $mail->method($method)->willReturnCallback( + function (array $data) use ($method) { + $this->trace[] = ['mail.' . $method, $data['name'] ?? null]; + $answer = ($this->plan['mail'] ?? true); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + } + + return $mail; + + }//end emailService() + + /** + * An organisation entity double that records its writes. + * + * @param string $uuid The entity uuid. + * @param bool $active Whether it is active. + * + * @return Organisation + */ + public function orgEntity(string $uuid, bool $active): Organisation { + $trace = &$this->trace; + return new class($uuid, $active, $trace) extends Organisation { + /** + * @param string $entityUuid The uuid. + * @param bool $isActive Whether it is active. + * @param array $trace The shared trace. + */ + public function __construct(private string $entityUuid, private bool $isActive, private array &$trace) { + } + + /** + * @return int|null + */ + public function getId(): ?int { + return 41; + } + + /** + * @return string + */ + public function getUuid(): string { + return $this->entityUuid; + } + + /** + * @param string $uuid The uuid. + * + * @return void + */ + public function setUuid(string $uuid): void { + $this->trace[] = ['entity.setUuid', $this->entityUuid, $uuid]; + $this->entityUuid = $uuid; + } + + /** + * @return bool + */ + public function getActive(): bool { + return $this->isActive; + } + + /** + * @param bool $active Whether it is active. + * + * @return void + */ + public function setActive(bool $active): void { + $this->trace[] = ['entity.setActive', $this->entityUuid, $active]; + $this->isActive = $active; + } + + /** + * @return string + */ + public function getName(): string { + return 'Entity ' . $this->entityUuid; + } + }; + + }//end orgEntity() + + /** + * The cases: the organisatie object, the e-mail flag and the doubles' answers. + * + * @return array + */ + public static function entityCases(): array { + $missing = fn () => new \OCP\AppFramework\Db\DoesNotExistException('missing'); + return [ + 'full fetch fails, entity in sync' => [['name' => 'Gemeente A', 'status' => 'actief'], true, fn ($t) => ['find' => [new \RuntimeException('find failed')], 'org' => [$t->orgEntity('o-1', true)]]], + 'full object replaces the given one' => [['name' => 'A'], true, fn ($t) => ['find' => [$t->entity(['name' => 'Gemeente B', 'status' => 'Active', '@self' => ['owner' => 'o-1'], 'organisation' => 'o-1'], 'o-1')], 'org' => [$t->orgEntity('o-1', true)]]], + 'existing entity deactivated' => [['name' => 'Gemeente C', 'status' => 'inactief'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', true)]]], + 'existing entity activated, mail sent' => [['name' => 'Gemeente D'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', false)]]], + 'existing entity activated, mail not sent' => [['name' => 'Gemeente E', 'status' => 'ACTIVE'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', false)], 'mail' => false]], + 'existing entity activated, mail throws' => [['name' => 'Gemeente E2'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', false)], 'mail' => new \RuntimeException('smtp down')]], + 'existing entity activated, no mails' => [['name' => 'Gemeente F'], false, fn ($t) => ['org' => [$t->orgEntity('o-1', false)]]], + 'adopted by slug' => [['name' => 'Gemeente G & Zn!', 'status' => 'actief'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$t->orgEntity('o-old', true)]]], + 'not found, created with mail' => [['name' => 'Gemeente H'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()]]], + 'not found, created, mail not sent' => [['name' => 'Gemeente H2'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()], 'mail' => false]], + 'not found, created without mail' => [['name' => 'Gemeente I'], false, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()]]], + 'no name, created' => [['status' => 'actief'], true, fn ($t) => ['org' => [$missing()]]], + 'creation fails' => [['name' => 'Gemeente J'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()], 'created' => null]], + 'mapper fails' => [['name' => 'Gemeente K'], true, fn ($t) => ['org' => [new \RuntimeException('db down')]]], + 'owner save fails' => [['name' => 'Gemeente L'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', true)], 'save' => [new \RuntimeException('save failed')]]], + 'settings fail' => [['name' => 'Gemeente M'], true, fn ($t) => ['config' => new \RuntimeException('settings down')]], + ]; + + }//end entityCases() + + /** + * Each organisation entity case matches its recording. + * + * @param array $object The organisatie object data. + * @param bool $sendEmails Whether e-mails are sent. + * @param \Closure $plan Builds the doubles' answers. + * + * @return void + * + * @dataProvider entityCases + */ + public function testEnsureEntity(array $object, bool $sendEmails, \Closure $plan): void { + $this->trace = []; + $this->plan = array_merge(['find' => [null], 'save' => [], 'org' => [], 'slug' => []], $plan($this)); + $service = $this->service(); + $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0]; + $entity = $service->ensureOrganisationEntityPublic($this->entity(object: $object, uuid: 'o-1'), $stats, $sendEmails); + $returned = null; + if ($entity !== null) { + $returned = [$entity->getUuid(), $entity->getActive()]; + } + + $this->check(key: 'entity: ' . $this->dataName(), value: ['returned' => $returned, 'stats' => $stats, 'trace' => $this->trace]); + + }//end testEnsureEntity() + + /** + * The batch back-fill creates missing organisation entities without mailing them. + * + * @return void + */ + public function testTheBackfillSendsNoMail(): void { + $this->trace = []; + $missing = new \OCP\AppFramework\Db\DoesNotExistException('missing'); + $this->plan = [ + 'config' => ['register' => '6', 'organisatie_schema' => '9'], + 'find' => [$this->entity(object: ['name' => 'Gemeente N', 'status' => 'actief'], uuid: 'o-1'), null], + 'save' => [], + 'org' => [$missing], + 'slug' => [$missing], + ]; + $service = $this->service(); + (new \ReflectionProperty(OrganizationSyncService::class, 'db'))->setValue($service, $this->batchDatabase(rows: [['uuid' => 'o-1', 'status' => 'actief']])); + + $stats = $service->performOrganizationsSync(); + + self::assertSame(1, $stats['entitiesCreated'], 'the back-fill must reach the entity creation'); + self::assertSame([], array_values(array_filter($this->trace, fn (array $call) => str_starts_with($call[0], 'mail.')))); + }//end testTheBackfillSendsNoMail() + + /** + * A created organisation processed on its own gets its registration mail. + * + * @return void + */ + public function testProcessSpecificOrganizationMailsOnCreate(): void { + $this->trace = []; + $missing = new \OCP\AppFramework\Db\DoesNotExistException('missing'); + $this->plan = ['find' => [null], 'save' => [], 'org' => [$missing], 'slug' => [$missing]]; + + $this->service()->processSpecificOrganization($this->entity(object: ['name' => 'Gemeente O', 'status' => 'actief'], uuid: 'o-1')); + + self::assertContains(['mail.sendOrganizationRegistrationEmail', 'Gemeente O'], $this->trace); + }//end testProcessSpecificOrganizationMailsOnCreate() + + /** + * A database whose count and batch queries answer the given rows. + * + * @param array> $rows The organisations without an entity. + * + * @return \OCP\IDBConnection + */ + private function batchDatabase(array $rows): \OCP\IDBConnection { + $result = $this->createMock(originalClassName: \OCP\DB\IResult::class); + $result->method('fetchOne')->willReturn(count($rows)); + $result->method('fetchAll')->willReturn($rows); + + $query = $this->createMock(originalClassName: \OCP\DB\QueryBuilder\IQueryBuilder::class); + foreach (['select', 'from', 'leftJoin', 'where', 'andWhere', 'orderBy', 'setMaxResults'] as $method) { + $query->method($method)->willReturnSelf(); + } + + $query->method('createFunction')->willReturn($this->createMock(originalClassName: \OCP\DB\QueryBuilder\IQueryFunction::class)); + $query->method('createNamedParameter')->willReturn(':status'); + $query->method('executeQuery')->willReturn($result); + + $database = $this->createMock(originalClassName: \OCP\IDBConnection::class); + $database->method('getQueryBuilder')->willReturn($query); + return $database; + }//end batchDatabase() + + /** + * Compare a case with the golden file, or record it. + * + * @param string $key The case. + * @param array $value What the case observed. + * + * @return void + */ + private function check(string $key, array $value): void { + $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); + $file = self::golden(); + $saved = []; + if (file_exists($file) === true) { + $saved = json_decode((string) file_get_contents($file), true); + } + + if (getenv('RECORD_GOLDEN') !== false) { + $saved[$key] = $json; + if (is_dir(dirname($file)) === false) { + mkdir(dirname($file), 0777, true); + } + + file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); + $this->addToAssertionCount(count: 1); + return; + } + + $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); + $this->assertSame(expected: $saved[$key], actual: $json); + + }//end check() + +}//end class diff --git a/tests/Unit/Service/OrganizationSyncRelatedContactsGoldenTest.php b/tests/Unit/Service/OrganizationSyncRelatedContactsGoldenTest.php new file mode 100644 index 000000000..9928614e1 --- /dev/null +++ b/tests/Unit/Service/OrganizationSyncRelatedContactsGoldenTest.php @@ -0,0 +1,642 @@ + + * @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/organization-sync/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\Stackiq\Service\OrganisatieService; +use OCA\Stackiq\Service\OrganizationSyncService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; +use OCA\Stackiq\Service\SymfonyEmailService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUser; +use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; + +/** + * Golden cases for the related contact person sync. + * + * @covers \OCA\Stackiq\Service\OrganizationSyncService + * + * @spec openspec/specs/organization-sync/spec.md + */ +class OrganizationSyncRelatedContactsGoldenTest extends TestCase { + + /** + * Everything the doubles saw, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Per-case behaviour of the doubles. + * + * @var array + */ + private array $plan = []; + + /** + * The golden file. + * + * @return string + */ + private static function golden(): string { + return __DIR__ . '/golden/organizationSyncRelatedContacts.json'; + + }//end golden() + + /** + * A contact or organisation object double that records its writes. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * + * @return ObjectEntityInterface + */ + private function entity(array $object, string $uuid): ObjectEntityInterface { + $trace = &$this->trace; + return new class($object, $uuid, $trace) implements ObjectEntityInterface { + /** + * The owner set on the entity. + * + * @var string|null + */ + private ?string $owner = null; + + /** + * The organisation set on the entity. + * + * @var string|null + */ + private ?string $organisation = 'org-of-entity'; + + /** + * Build the double. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * @param array $trace The shared trace. + */ + public function __construct(private array $object, private string $uuid, private array &$trace) { + } + + /** + * @return mixed + */ + public function getId() { + return 7; + } + + /** + * @return string|null + */ + public function getUuid(): ?string { + return $this->uuid; + } + + /** + * @return array + */ + public function getObject(): array { + return $this->object; + } + + /** + * @return string|null + */ + public function getRegister(): ?string { + return 'reg-entity'; + } + + /** + * @return string|null + */ + public function getSchema(): ?string { + return 'schema-entity'; + } + + /** + * @return string|null + */ + public function getOrganisation(): ?string { + return $this->organisation; + } + + /** + * @return string|null + */ + public function getOwner(): ?string { + return $this->owner; + } + + /** + * @param mixed $object The data. + * + * @return void + */ + public function setObject($object=null) { + $this->trace[] = ['entity.setObject', $this->uuid, $object]; + $this->object = $object; + } + + /** + * @param string|null $owner The owner. + * + * @return void + */ + public function setOwner(?string $owner): void { + $this->trace[] = ['entity.setOwner', $this->uuid, $owner]; + $this->owner = $owner; + } + + /** + * @param string|null $organisation The organisation. + * + * @return void + */ + public function setOrganisation(?string $organisation): void { + $this->trace[] = ['entity.setOrganisation', $this->uuid, $organisation]; + $this->organisation = $organisation; + } + + /** + * @return mixed + */ + public function jsonSerialize(): mixed { + return $this->object; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + $this->trace[] = ['entity.' . $name, $this->uuid, $args]; + return null; + } + }; + + }//end entity() + + /** + * Arguments by parameter name, leaving out the ones left at their default. + * + * @param string $method The ObjectServiceInterface method. + * @param array $args The positional arguments the double received. + * + * @return array + */ + private static function named(string $method, array $args): array { + $named = []; + foreach ((new \ReflectionMethod(ObjectServiceInterface::class, $method))->getParameters() as $i => $param) { + if (array_key_exists($i, $args) === false) { + continue; + } + + if ($param->isDefaultValueAvailable() === true && $param->getDefaultValue() === $args[$i]) { + continue; + } + + $value = $args[$i]; + if ($value instanceof ObjectEntityInterface) { + $value = 'entity:' . $value->getUuid(); + } + + $named[$param->getName()] = $value; + } + + return $named; + + }//end named() + + /** + * Build the service with recording doubles driven by $this->plan. + * + * @return OrganizationSyncService + */ + private function service(): OrganizationSyncService { + $trace = &$this->trace; + $logger = new class($trace) extends AbstractLogger { + /** + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context=[]): void { + foreach (['trace', 'file', 'line'] as $volatile) { + if (array_key_exists($volatile, $context) === true) { + $context[$volatile] = '(' . $volatile . ')'; + } + } + + $this->trace[] = ['log.' . $level, (string) $message, $context]; + } + }; + + $objects = $this->createMock(originalClassName: ObjectServiceInterface::class); + $objects->method('find')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.find', self::named(method: 'find', args: $args)]; + $answer = array_shift($this->plan['find']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $objects->method('searchObjects')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.searchObjects', self::named(method: 'searchObjects', args: $args)]; + $answer = array_shift($this->plan['search']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $objects->method('saveObject')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.saveObject', self::named(method: 'saveObject', args: $args)]; + $answer = array_shift($this->plan['save']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return ($answer ?? $this->entity(object: ['saved' => true], uuid: 'saved-uuid')); + } + ); + + $mapper = $this->createMock(originalClassName: OrganisationMapper::class); + $mapper->method('findByUuid')->willReturnCallback( + function (string $uuid) { + $this->trace[] = ['mapper.findByUuid', $uuid]; + $answer = array_shift($this->plan['org']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + $handler = $this->createMock(originalClassName: ContactPersonHandler::class); + $handler->method('isFirstContactForOrganization')->willReturnCallback( + function ($contact, $data) { + $this->trace[] = ['handler.isFirst', $contact->getUuid(), $data]; + return $this->plan['first'] ?? false; + } + ); + $handler->method('createUserAccount')->willReturnCallback( + function ($contact, $isFirst) { + $this->trace[] = ['handler.createUserAccount', $contact->getUuid(), $isFirst]; + return ($this->plan['user'] ?? null); + } + ); + $handler->method('addUserToOrganizationEntity')->willReturnCallback( + function (...$args) { + $args[0] = $args[0]->getUuid(); + $this->trace[] = ['handler.addUserToOrganizationEntity', $args]; + return true; + } + ); + + $settings = $this->createMock(originalClassName: SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturnCallback( + function () { + $this->trace[] = ['settings.getVoorzieningenConfig']; + $answer = ($this->plan['config'] ?? ['register' => 'reg-v', 'organisatie_schema' => 'org-schema']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + // Built without the constructor: it type-hints IDBConnection, whose + // mock needs Doctrine, and the methods under test never touch it. + $reflection = new \ReflectionClass(OrganizationSyncService::class); + $service = $this->getMockBuilder(OrganizationSyncService::class) + ->disableOriginalConstructor() + ->onlyMethods(['processSpecificContactPerson']) + ->getMock(); + $service->method('processSpecificContactPerson')->willReturnCallback( + function ($contactObject) { + $this->trace[] = ['service.processSpecificContactPerson', $contactObject->getUuid(), $contactObject->getObject()]; + $answer = array_shift($this->plan['process']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return ($answer ?? ['contactPersonsProcessed' => 1, 'usersCreated' => 0, 'usersUpdated' => 1, 'errors' => []]); + } + ); + $properties = [ + 'logger' => $logger, + 'settingsService' => $settings, + 'contactpersonHandler' => $handler, + 'objectService' => $objects, + 'organisationMapper' => $mapper, + 'organisationService' => $this->organisationService(), + 'emailService' => $this->createMock(originalClassName: SymfonyEmailService::class), + ]; + foreach ($properties as $name => $value) { + $reflection->getProperty($name)->setValue($service, $value); + } + + return $service; + + }//end service() + + /** + * An organisation service double that records the organisations it creates. + * + * @return OrganisatieService + */ + private function organisationService(): OrganisatieService { + $service = $this->createMock(originalClassName: OrganisatieService::class); + $service->method('createOrganisationInOpenRegister')->willReturnCallback( + function (array $data) { + $this->trace[] = ['organisations.create', $data]; + return self::organisation(active: true); + } + ); + return $service; + + }//end organisationService() + + /** + * An organisation entity double. + * + * @param mixed $active What getActive() answers, or a Throwable it throws. + * + * @return Organisation + */ + private static function organisation(mixed $active): Organisation { + return new class($active) extends Organisation { + /** + * @param mixed $active The answer. + */ + public function __construct(private mixed $active) { + } + + /** + * @return mixed + */ + public function getActive(): mixed { + if ($this->active instanceof \Throwable) { + throw $this->active; + } + + return $this->active; + } + + /** + * @return string + */ + public function getName(): string { + return 'Org'; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + return null; + } + }; + + }//end organisation() + + /** + * A user double. + * + * @param string $uid The uid. + * + * @return IUser + */ + private function user(string $uid): IUser { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn($uid); + return $user; + + }//end user() + + /** + * The cases: organisation uuid and the doubles' answers. + * + * @return array + */ + public static function relatedCases(): array { + $config = ['register' => '5', 'contactpersoon_schema' => '9', 'organisatie_schema' => '7']; + return [ + 'no contact schema' => ['org-1', fn ($t) => ['config' => ['register' => '5']]], + 'no register' => ['org-1', fn ($t) => ['config' => ['contactpersoon_schema' => '9']]], + 'found by organization field' => [ + 'org-1', + fn ($t) => [ + 'config' => $config, + 'search' => [[ + $t->entity(['email' => 'a@x.nl', 'organization' => 'org-1'], 'c-a'), + $t->entity(['e-mailadres' => 'b@x.nl'], 'c-b'), + $t->entity(['voornaam' => 'C', 'organization' => 'org-1'], 'c-c'), + ], + ], + 'find' => [$t->entity(['email' => 'c@x.nl', 'organization' => 'org-1'], 'c-c')], + 'process' => [null, ['contactPersonsProcessed' => 1, 'usersCreated' => 1, 'usersUpdated' => 0, 'errors' => ['user b failed']], []], + ], + ], + 'found by organisation field' => [ + 'org-2', + fn ($t) => [ + 'config' => $config, + 'search' => [[], [$t->entity(['email' => 'd@x.nl', 'organization' => 'org-2'], 'c-d')]], + ], + ], + 'fallback to the contactpersonen array' => [ + 'org-3', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [ + $t->entity(['contactpersonen' => ['c-e', '', 42, 'c-gone', 'c-null', 'c-f']], 'org-3'), + $t->entity(['email' => 'e@x.nl'], 'c-e'), + new \RuntimeException('contact gone'), + null, + $t->entity(['email' => 'f@x.nl', 'organization' => 'org-other'], 'c-f'), + ], + ], + ], + 'fallback org has no contacts' => [ + 'org-4', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [$t->entity(['naam' => 'Org 4'], 'org-4')], + ], + ], + 'fallback org missing' => [ + 'org-5', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [null], + ], + ], + 'fallback org lookup throws' => [ + 'org-6', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [new \RuntimeException('org gone')], + ], + ], + 'refetch throws, process throws' => [ + 'org-7', + fn ($t) => [ + 'config' => $config, + 'search' => [[$t->entity(['organization' => 'org-7'], 'c-g'), $t->entity(['email' => 'h@x.nl'], 'c-h')]], + 'find' => [new \RuntimeException('refetch failed')], + 'process' => [new \RuntimeException('process failed'), null], + ], + ], + 'refetch returns null' => [ + 'org-8', + fn ($t) => [ + 'config' => $config, + 'search' => [[$t->entity(['organization' => 'org-8'], 'c-i')]], + 'find' => [null], + ], + ], + 'save of the organisation field throws' => [ + 'org-9', + fn ($t) => [ + 'config' => $config, + 'search' => [[$t->entity(['email' => 'j@x.nl'], 'c-j')]], + 'save' => [new \RuntimeException('save failed')], + ], + ], + 'search throws' => [ + 'org-10', + fn ($t) => [ + 'config' => $config, + 'search' => [new \RuntimeException('search failed')], + ], + ], + 'settings throw' => ['org-11', fn ($t) => ['config' => new \RuntimeException('settings down')]], + ]; + + }//end relatedCases() + + /** + * Each related contact case matches its recording. + * + * @param string $organizationUuid The organisation uuid. + * @param \Closure $plan Builds the doubles' answers. + * + * @return void + * + * @dataProvider relatedCases + */ + public function testRelatedContacts(string $organizationUuid, \Closure $plan): void { + $this->trace = []; + $this->plan = array_merge(['find' => [], 'save' => [], 'org' => [], 'search' => [], 'process' => []], $plan($this)); + $service = $this->service(); + $stats = ['contactPersonsProcessed' => 0, 'usersCreated' => 0, 'usersUpdated' => 0, 'errors' => ['earlier error']]; + $this->callRelated(service: $service, organizationUuid: $organizationUuid, stats: $stats); + $this->check(key: 'related: ' . $this->dataName(), value: ['stats' => $stats, 'trace' => $this->trace]); + + }//end testRelatedContacts() + + /** + * Call processRelatedContactPersons with the organisation uuid and the stats. + * + * @param OrganizationSyncService $service The service. + * @param string $organizationUuid The organisation uuid. + * @param array $stats The stats, updated in place. + * + * @return void + */ + private function callRelated(OrganizationSyncService $service, string $organizationUuid, array &$stats): void { + $method = new \ReflectionMethod(OrganizationSyncService::class, 'processRelatedContactPersons'); + $args = [$organizationUuid]; + if ($method->getNumberOfParameters() === 3) { + $args[] = $this->entity(object: ['naam' => 'Org'], uuid: $organizationUuid); + } + + $args[] = &$stats; + $method->invokeArgs($service, $args); + + }//end callRelated() + + /** + * Compare a case with the golden file, or record it. + * + * @param string $key The case. + * @param array $value What the case observed. + * + * @return void + */ + private function check(string $key, array $value): void { + $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); + $file = self::golden(); + $saved = []; + if (file_exists($file) === true) { + $saved = json_decode((string) file_get_contents($file), true); + } + + if (getenv('RECORD_GOLDEN') !== false) { + $saved[$key] = $json; + if (is_dir(dirname($file)) === false) { + mkdir(dirname($file), 0777, true); + } + + file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); + $this->addToAssertionCount(count: 1); + return; + } + + $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); + $this->assertSame(expected: $saved[$key], actual: $json); + + }//end check() + +}//end class diff --git a/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json b/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json new file mode 100644 index 000000000..9a435c71d --- /dev/null +++ b/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json @@ -0,0 +1,1654 @@ +{ + "[\"\",[],true,[]]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "UUID is required" + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "", + "options": [], + "isAmbtenaar": true + } + ] + ] + }, + "[\"uuid-1\",[],true,{\"config\":{\"register\":\"reg-1\",\"gebruik_schema\":\"schema-gebruik\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "Failed to retrieve objects: Voorzieningen configuration not found. Please configure the schemas in the admin panel." + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-1", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.error", + "Failed to get koppelingen-gebruik by UUID", + { + "uuid": "uuid-1", + "error": "Voorzieningen configuration not found. Please configure the schemas in the admin panel." + } + ] + ] + }, + "[\"uuid-1\",[],true,{\"noOpenRegister\":true}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "Failed to retrieve objects: OpenRegister app is not installed" + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-1", + "options": [], + "isAmbtenaar": true + } + ], + [ + "log.error", + "Failed to get koppelingen-gebruik by UUID", + { + "uuid": "uuid-1", + "error": "OpenRegister app is not installed" + } + ] + ] + }, + "[\"uuid-mod\",{\"organisation\":\"org-x\",\"_limit\":5},true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "organisation": "org-x", + "_limit": 5 + }, + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + { + "organisation": "org-x", + "_limit": 5 + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "org-x" + }, + "_limit": 5, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "org-x" + }, + "_limit": 5, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",{\"_page\":2},true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "_page": 2 + }, + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + { + "_page": 2 + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-org\",{\"organisation\":\"org-x\"},true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-organisatie\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-org", + "options": { + "organisation": "org-x" + }, + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + { + "organisation": "org-x" + } + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "log.warning", + "Organisation filter parameter is ignored when UUID is already an organisation", + { + "uuid": "uuid-org", + "filter": "org-x" + } + ], + [ + "log.debug", + "Executing koppelingen-gebruik by organisation UUID", + { + "uuid": "uuid-org", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": null + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-org", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-org\",[],true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"15\"},\"config\":{\"register\":\"reg-1\",\"gebruik_schema\":\"schema-gebruik\",\"koppeling_schema\":\"schema-koppeling\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-org", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by organisation UUID", + { + "uuid": "uuid-org", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": null + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-org", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],true,{\"findThrows\":true}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Could not fetch UUID object, assuming it is not an organisation", + { + "uuid": "uuid-mod" + } + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],true,{\"found\":null}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],true,{\"noUser\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",{\"organisation\":\"org-forged\"},false,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "organisation": "org-forged" + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "objects.buildSearchQuery", + { + "organisation": "org-forged" + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-org\",[],false,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-organisatie\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-org", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by organisation UUID", + { + "uuid": "uuid-org", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": null + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-org", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":null,\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"noUser\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ] + ] + }, + "[\"uuid-mod\",[],false,{\"noActiveOrg\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ] + ] + }, + "[\"uuid-mod\",[],false,{\"orgThrows\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.error", + "Failed to get current organisation from OpenRegister", + { + "error": "organisation service down" + } + ] + ] + }, + "[\"uuid-mod\",[],false,{\"findThrows\":true}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.warning", + "Failed to check ownership for UUID", + { + "uuid": "uuid-mod", + "error": "find failed" + } + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":null}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],true,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"},\"searchThrows\":true}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "Failed to retrieve objects: search failed" + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.error", + "Failed to get koppelingen-gebruik by UUID", + { + "uuid": "uuid-mod", + "error": "search failed" + } + ] + ] + }, + "[\"uuid-mod\",{\"organisation\":\"org-other\"},false,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "organisation": "org-other" + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":\"ORG-ACTING\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":\"org-acting \",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",{\"@self\":{\"organisation\":\"org-acting\",\"register\":\"reg-forged\"},\"isAmbtenaar\":true,\"organisation\":\"org-other\"},false,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "@self": { + "organisation": "org-acting", + "register": "reg-forged" + }, + "isAmbtenaar": true, + "organisation": "org-other" + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",{\"@self\":{\"register\":\"reg-forged\",\"schema\":\"schema-forged\"}},false,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "@self": { + "register": "reg-forged", + "schema": "schema-forged" + } + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "objects.buildSearchQuery", + { + "@self": { + "register": "reg-forged", + "schema": "schema-forged" + } + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + } +} diff --git a/tests/Unit/Service/golden/organizationSyncEntity.json b/tests/Unit/Service/golden/organizationSyncEntity.json new file mode 100644 index 000000000..02b4560ab --- /dev/null +++ b/tests/Unit/Service/golden/organizationSyncEntity.json @@ -0,0 +1,1852 @@ +{ + "entity: full fetch fails, entity in sync": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "Could not fetch full organisation object, using provided object", + { + "organisatieId": "o-1", + "error": "find failed" + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente A", + "status": "actief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": false + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente A", + "status": "actief", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente A", + "status": "actief", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: full object replaces the given one": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente B", + "status": "Active" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": false + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "log.debug", + "OrganizationSyncService: Owner and organisation already set correctly, skipping update", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1" + } + ] + ] + }, + "entity: existing entity deactivated": { + "returned": [ + "o-1", + false + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente C", + "status": "inactief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": false, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": true, + "newActive": false + } + ], + [ + "entity.setActive", + "o-1", + false + ], + [ + "mapper.save", + "o-1", + false + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente C", + "status": "inactief", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente C", + "status": "inactief", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, mail sent": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente D", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "[FLOW] Sending organization activation email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationActivationEmail", + "Gemeente D" + ], + [ + "log.info", + "📧 Organization activation email sent successfully", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente D", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente D", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, mail not sent": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente E", + "status": "ACTIVE" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "[FLOW] Sending organization activation email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationActivationEmail", + "Gemeente E" + ], + [ + "log.info", + "📧 Organization activation email not sent (disabled or not configured)", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente E", + "status": "ACTIVE", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente E", + "status": "ACTIVE", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, mail throws": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente E2", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "[FLOW] Sending organization activation email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationActivationEmail", + "Gemeente E2" + ], + [ + "log.warning", + "OrganizationSyncService: Organization activation email failed", + { + "organizationId": "unknown", + "organizationName": "Gemeente E2", + "error": "smtp down" + } + ], + [ + "log.info", + "📧 Organization activation email not sent (disabled or not configured)", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente E2", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente E2", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, no mails": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente F", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente F", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente F", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: adopted by slug": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente G & Zn!", + "status": "actief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-g-zn" + ], + [ + "log.info", + "OrganizationSyncService: Found existing entity by slug, updating UUID to match object", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldEntityUuid": "o-old", + "slug": "gemeente-g-zn" + } + ], + [ + "entity.setUuid", + "o-old", + "o-1" + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente G & Zn!", + "status": "actief", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente G & Zn!", + "status": "actief", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: not found, created with mail": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-h" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H" + } + ], + [ + "organisations.create", + { + "name": "Gemeente H" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "[FLOW] Sending organization registration email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationRegistrationEmail", + "Gemeente H" + ], + [ + "log.info", + "📧 Organization registration email sent successfully", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente H", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente H", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: not found, created, mail not sent": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H2", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-h2" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H2" + } + ], + [ + "organisations.create", + { + "name": "Gemeente H2" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "[FLOW] Sending organization registration email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationRegistrationEmail", + "Gemeente H2" + ], + [ + "log.info", + "📧 Organization registration email not sent (disabled or not configured)", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente H2", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente H2", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: not found, created without mail": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente I", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-i" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente I" + } + ], + [ + "organisations.create", + { + "name": "Gemeente I" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente I", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente I", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: no name, created": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Unknown", + "status": "actief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Unknown" + } + ], + [ + "organisations.create", + { + "status": "actief" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "[FLOW] Sending organization registration email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationRegistrationEmail", + null + ], + [ + "log.info", + "📧 Organization registration email sent successfully", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "status": "actief", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "status": "actief", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: creation fails": { + "returned": null, + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente J", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-j" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente J" + } + ], + [ + "organisations.create", + { + "name": "Gemeente J" + } + ], + [ + "log.error", + "❌ ORGANISATION ENTITY CREATION FAILED", + { + "app": "stackiq", + "organisatieId": "o-1" + } + ] + ] + }, + "entity: mapper fails": { + "returned": null, + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente K", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.error", + "💥 ENSURE ORGANISATION ENTITY EXCEPTION", + { + "app": "stackiq", + "organisatieId": 7, + "exception": "db down", + "file": "(file)", + "line": "(line)", + "trace": "(trace)" + } + ] + ] + }, + "entity: owner save fails": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente L", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": false + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente L", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente L", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.error", + "OrganizationSyncService: Failed to update organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "exception": "save failed", + "file": "(file)", + "line": "(line)" + } + ] + ] + }, + "entity: settings fail": { + "returned": null, + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente M", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.error", + "💥 ENSURE ORGANISATION ENTITY EXCEPTION", + { + "app": "stackiq", + "organisatieId": 7, + "exception": "settings down", + "file": "(file)", + "line": "(line)", + "trace": "(trace)" + } + ] + ] + } +} diff --git a/tests/Unit/Service/golden/organizationSyncRelatedContacts.json b/tests/Unit/Service/golden/organizationSyncRelatedContacts.json new file mode 100644 index 000000000..57d807780 --- /dev/null +++ b/tests/Unit/Service/golden/organizationSyncRelatedContacts.json @@ -0,0 +1,1374 @@ +{ + "related: no contact schema": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-1" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "[FLOW] Related contact processing skipped - configuration missing", + { + "organizationId": "org-1", + "register": "5", + "contactSchema": "" + } + ] + ] + }, + "related: no register": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-1" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "[FLOW] Related contact processing skipped - configuration missing", + { + "organizationId": "org-1", + "register": "", + "contactSchema": "9" + } + ] + ] + }, + "related: found by organization field": { + "stats": { + "contactPersonsProcessed": 2, + "usersCreated": 1, + "usersUpdated": 1, + "errors": [ + "earlier error", + "user b failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-1" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-1", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-1", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-1", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-1", + "contactCount": 3 + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-1", + "contactId": "c-a", + "contactEmail": "a@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-a", + { + "email": "a@x.nl", + "organization": "org-1" + } + ], + [ + "entity.setObject", + "c-b", + { + "e-mailadres": "b@x.nl", + "organization": "org-1" + } + ], + [ + "objects.saveObject", + { + "object": { + "e-mailadres": "b@x.nl", + "organization": "org-1" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-b", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on related contact", + { + "contactId": "c-b", + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-1", + "contactId": "c-b", + "contactEmail": "b@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-b", + { + "e-mailadres": "b@x.nl", + "organization": "org-1" + } + ], + [ + "log.info", + "[FLOW] Contact data incomplete, re-fetching full object", + { + "organizationId": "org-1", + "contactId": "c-c" + } + ], + [ + "objects.find", + { + "id": "c-c", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-1", + "contactId": "c-c", + "contactEmail": "c@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-c", + { + "email": "c@x.nl", + "organization": "org-1" + } + ] + ] + }, + "related: found by organisation field": { + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "usersUpdated": 1, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-2" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-2", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-2", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-2", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-2", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-2" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-2" + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-2", + "contactCount": 1 + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-2", + "contactId": "c-d", + "contactEmail": "d@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-d", + { + "email": "d@x.nl", + "organization": "org-2" + } + ] + ] + }, + "related: fallback to the contactpersonen array": { + "stats": { + "contactPersonsProcessed": 2, + "usersCreated": 0, + "usersUpdated": 2, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-3" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-3", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-3", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-3", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-3", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-3" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-3" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-3" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-3", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Raw org contactpersonen array", + { + "organizationId": "org-3", + "contactpersonen": [ + "c-e", + "", + 42, + "c-gone", + "c-null", + "c-f" + ], + "count": 6 + } + ], + [ + "objects.find", + { + "id": "c-e", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "objects.find", + { + "id": "c-gone", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "[FLOW] Could not fetch contact from contactpersonen array", + { + "organizationId": "org-3", + "contactUuid": "c-gone", + "exception": "contact gone" + } + ], + [ + "objects.find", + { + "id": "c-null", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "objects.find", + { + "id": "c-f", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-3", + "contactCount": 2 + } + ], + [ + "entity.setObject", + "c-e", + { + "email": "e@x.nl", + "organization": "org-3" + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "e@x.nl", + "organization": "org-3" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-e", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on related contact", + { + "contactId": "c-e", + "organizationUuid": "org-3" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-3", + "contactId": "c-e", + "contactEmail": "e@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-e", + { + "email": "e@x.nl", + "organization": "org-3" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-3", + "contactId": "c-f", + "contactEmail": "f@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-f", + { + "email": "f@x.nl", + "organization": "org-other" + } + ] + ] + }, + "related: fallback org has no contacts": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-4" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-4", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-4", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-4", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-4", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-4" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-4" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-4" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-4", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Raw org contactpersonen array", + { + "organizationId": "org-4", + "contactpersonen": [], + "count": 0 + } + ], + [ + "log.info", + "[FLOW] No related contact persons found (including contactpersonen fallback)", + { + "organizationId": "org-4" + } + ] + ] + }, + "related: fallback org missing": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-5" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-5", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-5", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-5", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-5", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-5" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-5" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-5" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-5", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Raw org contactpersonen array", + { + "organizationId": "org-5", + "contactpersonen": [], + "count": 0 + } + ], + [ + "log.info", + "[FLOW] No related contact persons found (including contactpersonen fallback)", + { + "organizationId": "org-5" + } + ] + ] + }, + "related: fallback org lookup throws": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-6" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-6", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-6", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-6", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-6", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-6" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-6" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-6" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-6", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "[FLOW] Could not re-fetch org for contactpersonen fallback", + { + "organizationId": "org-6", + "exception": "org gone" + } + ], + [ + "log.info", + "[FLOW] No related contact persons found (including contactpersonen fallback)", + { + "organizationId": "org-6" + } + ] + ] + }, + "related: refetch throws, process throws": { + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "usersUpdated": 1, + "errors": [ + "earlier error", + "Related contact c-g: process failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-7" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-7", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-7", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-7", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-7", + "contactCount": 2 + } + ], + [ + "log.info", + "[FLOW] Contact data incomplete, re-fetching full object", + { + "organizationId": "org-7", + "contactId": "c-g" + } + ], + [ + "objects.find", + { + "id": "c-g", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "[FLOW] Failed to re-fetch contact object", + { + "organizationId": "org-7", + "contactId": "c-g", + "exception": "refetch failed" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-7", + "contactId": "c-g", + "contactEmail": "unknown" + } + ], + [ + "service.processSpecificContactPerson", + "c-g", + { + "organization": "org-7" + } + ], + [ + "log.error", + "[FLOW] Failed to process related contact person", + { + "organizationId": "org-7", + "contactId": "c-g", + "exception": "process failed" + } + ], + [ + "entity.setObject", + "c-h", + { + "email": "h@x.nl", + "organization": "org-7" + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "h@x.nl", + "organization": "org-7" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-h", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on related contact", + { + "contactId": "c-h", + "organizationUuid": "org-7" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-7", + "contactId": "c-h", + "contactEmail": "h@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-h", + { + "email": "h@x.nl", + "organization": "org-7" + } + ] + ] + }, + "related: refetch returns null": { + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "usersUpdated": 1, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-8" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-8", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-8", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-8", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-8", + "contactCount": 1 + } + ], + [ + "log.info", + "[FLOW] Contact data incomplete, re-fetching full object", + { + "organizationId": "org-8", + "contactId": "c-i" + } + ], + [ + "objects.find", + { + "id": "c-i", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-8", + "contactId": "c-i", + "contactEmail": "unknown" + } + ], + [ + "service.processSpecificContactPerson", + "c-i", + { + "organization": "org-8" + } + ] + ] + }, + "related: save of the organisation field throws": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error", + "Related contact c-j: save failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-9" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-9", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-9", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-9", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-9", + "contactCount": 1 + } + ], + [ + "entity.setObject", + "c-j", + { + "email": "j@x.nl", + "organization": "org-9" + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "j@x.nl", + "organization": "org-9" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-j", + "silent": true, + "_validation": false + } + ], + [ + "log.error", + "[FLOW] Failed to process related contact person", + { + "organizationId": "org-9", + "contactId": "c-j", + "exception": "save failed" + } + ] + ] + }, + "related: search throws": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error", + "Failed to process related contact persons: search failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-10" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-10", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-10", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-10", + "_limit": 500 + } + } + ], + [ + "log.error", + "[FLOW] Failed to process related contact persons", + { + "organizationId": "org-10", + "exception": "search failed", + "file": "(file)", + "line": "(line)" + } + ] + ] + }, + "related: settings throw": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error", + "Failed to process related contact persons: settings down" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-11" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.error", + "[FLOW] Failed to process related contact persons", + { + "organizationId": "org-11", + "exception": "settings down", + "file": "(file)", + "line": "(line)" + } + ] + ] + } +}