From 6c3372d135fc760191e7be36dedbc2481042f8a4 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 18:46:55 +0100 Subject: [PATCH] refactor(organization-sync): split the contact person sync into steps createOrUpdateContactPersonObject (298 lines, 5 parameters) and updateContactPersonObjectOwner (89 lines, 5 parameters) now take the sync run or the owner as one array and delegate to steps of at most two parameters: find or create the contact, keep its organisation, provision its user, link the user, and stage the owner. The run array holds the caller's stats by reference, so every step still counts into them as it goes, also when a later step throws. Behaviour is unchanged, pinned by a golden test recorded on the old code (23 cases: every branch of both methods, recorded by call and argument name). One redundant check went: the contact object can no longer be null after find-or-create. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/OrganizationSyncService.php | 999 ++++---- ...rganizationSyncContactPersonGoldenTest.php | 733 ++++++ .../golden/organizationSyncContactPerson.json | 2127 +++++++++++++++++ 3 files changed, 3430 insertions(+), 429 deletions(-) create mode 100644 tests/Unit/Service/OrganizationSyncContactPersonGoldenTest.php create mode 100644 tests/Unit/Service/golden/organizationSyncContactPerson.json diff --git a/lib/Service/OrganizationSyncService.php b/lib/Service/OrganizationSyncService.php index 8ae3062e3..f8dd147d8 100644 --- a/lib/Service/OrganizationSyncService.php +++ b/lib/Service/OrganizationSyncService.php @@ -1920,13 +1920,13 @@ private function processNestedContactPersons($organizationObject, array &$stats) ); // Create contact person object in OpenRegister if it doesn't exist. - $this->createOrUpdateContactPersonObject( - contactData: $contactData, - organizationUuid: $organizationUuid, - register: $register, - contactSchema: $contactSchema, - stats: $stats - ); + $run = [ + 'organizationUuid' => $organizationUuid, + 'register' => $register, + 'contactSchema' => $contactSchema, + 'stats' => &$stats, + ]; + $this->createOrUpdateContactPersonObject(contactData: $contactData, run: $run); } catch (\Exception $e) { $this->logger->error( '[FLOW] Failed to process nested contact person', @@ -2245,382 +2245,514 @@ private function processRelatedContactPersons(string $organizationUuid, $organiz }//end processRelatedContactPersons() /** - * Create or update a contact person object and user account + * Create or update a contact person object and its user account. * * @param array $contactData The contact person data. - * @param string $organizationUuid The organization UUID. - * @param string $register The register ID. - * @param string $contactSchema The contact schema ID. - * @param array $stats The statistics array to update. + * @param array $run The sync run: `organizationUuid`, `register`, `contactSchema` and, held by + * reference so every step updates the caller's counters, `stats`. * * @return void */ - private function createOrUpdateContactPersonObject( - array $contactData, - string $organizationUuid, - string $register, - string $contactSchema, - array &$stats, - ): void { + private function createOrUpdateContactPersonObject(array $contactData, array $run): void { try { $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); if (empty($email) === true) { $this->logger->warning( '[FLOW] Contact person has no email, skipping', [ - 'organizationId' => $organizationUuid, + 'organizationId' => $run['organizationUuid'], 'contactData' => array_keys($contactData), ] ); return; } + $run['email'] = $email; + // Check if contact already exists (has id from cascading or previous creation). $existingContactId = ($contactData['id'] ?? $contactData['uuid'] ?? null); - $contactObject = null; + $contactObject = $this->findExistingContactPerson(contactId: $existingContactId, run: $run); + if ($contactObject === null) { + $contactObject = $this->createContactPersonObject(contactData: $contactData, run: $run); + } - if (empty($existingContactId) === false) { - // Contact already exists - fetch it instead of trying to re-create. - $this->logger->info( - 'πŸ“§ FETCHING EXISTING CONTACT PERSON', - [ - 'app' => 'stackiq', - 'organizationId' => $organizationUuid, - 'contactId' => $existingContactId, - 'email' => $email, - ] - ); + $run['contact'] = $contactObject; + $run['wasExisting'] = empty($existingContactId) === false; + $this->completeContactPerson(run: $run); + } catch (\Exception $e) { + $this->logger->error( + '[FLOW] Failed to create/update contact person object', + [ + 'organizationId' => $run['organizationUuid'], + 'email' => ($contactData['email'] ?? $contactData['e-mailadres'] ?? 'unknown'), + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ] + ); + $run['stats']['errors'][] = 'Contact person creation failed: ' . $e->getMessage(); + }//end try - try { - $contactObject = $this->objectService->find( - id: $existingContactId, - register: $register, - schema: $contactSchema, - _rbac: false, - _multitenancy: false - ); - } catch (\Exception $e) { - $this->logger->warning( - '[FLOW] Could not fetch existing contact, will create new', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $existingContactId, - 'exception' => $e->getMessage(), - ] - ); - } - }//end if + }//end createOrUpdateContactPersonObject() - // If contact doesn't exist, create it (but don't set organisatie as string - it's handled by inversedBy). - if ($contactObject === null) { - $this->logger->info( - 'πŸ“§ CREATING NEW CONTACT PERSON OBJECT', - [ - 'app' => 'stackiq', - 'organizationId' => $organizationUuid, - 'email' => $email, - 'name' => ($contactData['voornaam'] ?? '') . ' ' . ($contactData['achternaam'] ?? ''), - ] - ); + /** + * Fetch the contact person a contact id points at, if there is one. + * + * @param mixed $contactId The id or uuid from the contact data, or null. + * @param array $run The sync run (see createOrUpdateContactPersonObject()). + * + * @return object|null The contact object, or null when there is no id or the fetch fails. + */ + private function findExistingContactPerson(mixed $contactId, array $run): ?object { + if (empty($contactId) === true) { + return null; + } - // Temporarily remove organisatie from contactData to avoid validation error. - // (schema expects object type but field stores a UUID string). - $savedOrganisation = $contactData['organization'] ?? null; - unset($contactData['organization']); - unset($contactData['id']); - unset($contactData['uuid']); + // Contact already exists - fetch it instead of trying to re-create. + $this->logger->info( + 'πŸ“§ FETCHING EXISTING CONTACT PERSON', + [ + 'app' => 'stackiq', + 'organizationId' => $run['organizationUuid'], + 'contactId' => $contactId, + 'email' => $run['email'], + ] + ); - $contactObject = $this->objectService->saveObject( - object: $contactData, - register: $register, - schema: $contactSchema, - _rbac: false, - _multitenancy: false - ); + try { + return $this->objectService->find( + id: $contactId, + register: $run['register'], + schema: $run['contactSchema'], + _rbac: false, + _multitenancy: false + ); + } catch (\Exception $e) { + $this->logger->warning( + '[FLOW] Could not fetch existing contact, will create new', + [ + 'organizationId' => $run['organizationUuid'], + 'contactId' => $contactId, + 'exception' => $e->getMessage(), + ] + ); + } - // Restore the organisatie field so the link to the organisation is preserved. - if ($savedOrganisation !== null) { - $restoredData = $contactObject->getObject(); - $restoredData['organization'] = $savedOrganisation; - // Published contract instead of OpenRegister's Db layer. The - // payload is threaded through explicitly rather than pushed - // into the entity with `setObject()` and read back out: - // `setObject()` is not on ObjectEntityInterface (ADR-084). - // The `$contactObject !== false` half of this guard also went: - // `saveObject()` declares a non-nullable entity, so it could - // never be false. - $this->objectService->saveObject( - object: array_merge( - $restoredData, - ['@self' => ['organisation' => $contactObject->getOrganisation()]] - ), - register: $contactObject->getRegister(), - schema: $contactObject->getSchema(), - uuid: $contactObject->getUuid(), - silent: true, - _validation: false - ); - } - }//end if + return null; - if ($contactObject !== null) { - $stats['contactPersonsProcessed']++; + }//end findExistingContactPerson() - // Ensure the contact has the organisatie field set (may be missing when linked via inversedBy). - $contactObjectData = $contactObject->getObject(); - if (empty($contactObjectData['organization']) === true && empty($organizationUuid) === false) { - $contactObjectData['organization'] = $organizationUuid; - // Published contract instead of OpenRegister's Db layer. - // `setObject()`/`setOrganisation()` are implementation-only - // accessors reached through Entity::__call() and are not on - // ObjectEntityInterface (ADR-084); the values they staged are - // carried in the payload sent below instead. - $this->objectService->saveObject( - object: array_merge( - $contactObjectData, - ['@self' => ['organisation' => $organizationUuid]] - ), - register: $contactObject->getRegister(), - schema: $contactObject->getSchema(), - uuid: $contactObject->getUuid(), - silent: true, - _validation: false - ); - $this->logger->info( - '[FLOW] Set missing organisatie field on contact person', - [ - 'contactId' => $contactObject->getUuid(), - 'organizationUuid' => $organizationUuid, - ] - ); - } + /** + * Create a contact person object, keeping its link to the organisation. + * + * The organisation field is left out of the create (the schema expects an + * object, the field stores a uuid) and written back afterwards; the + * organisation link itself is handled by inversedBy. + * + * @param array $contactData The contact person data. + * @param array $run The sync run (see createOrUpdateContactPersonObject()). + * + * @return object The created contact object. + */ + private function createContactPersonObject(array $contactData, array $run): object { + $this->logger->info( + 'πŸ“§ CREATING NEW CONTACT PERSON OBJECT', + [ + 'app' => 'stackiq', + 'organizationId' => $run['organizationUuid'], + 'email' => $run['email'], + 'name' => ($contactData['voornaam'] ?? '') . ' ' . ($contactData['achternaam'] ?? ''), + ] + ); - $this->logger->info( - 'βœ… CONTACT PERSON OBJECT READY', - [ - 'app' => 'stackiq', - 'organizationId' => $organizationUuid, - 'contactId' => $contactObject->getUuid(), - 'email' => $email, - 'wasExisting' => empty($existingContactId) === false, - ] - ); + $savedOrganisation = $contactData['organization'] ?? null; + unset($contactData['organization']); + unset($contactData['id']); + unset($contactData['uuid']); + + $contactObject = $this->objectService->saveObject( + object: $contactData, + register: $run['register'], + schema: $run['contactSchema'], + _rbac: false, + _multitenancy: false + ); - // Create user account if username is missing AND organization is active. - if (empty($contactObjectData['username']) === true) { - // Check if organization exists in organisation entity table. - $organisationEntity = null; - try { - $organisationEntity = $this->organisationMapper->findByUuid($organizationUuid); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - // Backup: org entity missing β€” create it now so user creation can proceed. - $this->logger->info( - 'Organisation entity missing for ' . $organizationUuid . ', creating backup entity', - [ - 'app' => 'stackiq', - 'contactId' => $contactObject->getUuid(), - ] - ); - try { - $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); - $orgObject = $this->objectService->find( - id: $organizationUuid, - register: ($voorzieningenConfig['register'] ?? ''), - schema: ($voorzieningenConfig['organisatie_schema'] ?? '') - ); - if (empty($orgObject) === false) { - $backupStats = [ - 'entitiesCreated' => 0, - 'entitiesUpdated' => 0, - ]; - $organisationEntity = $this->ensureOrganisationEntity( - organisationObject: $orgObject, - stats: $backupStats - ); - } - } catch (\Exception $backupEx) { - $this->logger->error( - 'Backup org entity creation failed', - [ - 'app' => 'stackiq', - 'organizationId' => $organizationUuid, - 'error' => $backupEx->getMessage(), - ] - ); - }//end try - }//end try + if ($savedOrganisation !== null) { + $restoredData = $contactObject->getObject(); + $restoredData['organization'] = $savedOrganisation; + $this->persistContactObject( + contactObject: $contactObject, + object: array_merge( + $restoredData, + ['@self' => ['organisation' => $contactObject->getOrganisation()]] + ) + ); + } - try { - if ($organisationEntity !== false && $organisationEntity->getActive() === true) { - // Determine if this is the first contact for the organization. - $isFirstContact = $this->contactpersonHandler->isFirstContactForOrganization( - $contactObject, - $contactObjectData - ); + return $contactObject; - $this->logger->info( - 'Creating user account for contact person (org is active)', - [ - 'app' => 'stackiq', - 'contactId' => $contactObject->getUuid(), - 'organizationId' => $organizationUuid, - 'email' => $email, - 'isFirstContact' => $isFirstContact, - ] - ); + }//end createContactPersonObject() - $user = $this->contactpersonHandler->createUserAccount($contactObject, $isFirstContact); - if (empty($user) === false) { - $stats['usersCreated']++; - - // FIX #434: Refresh contactObjectData from the entity AFTER. - // createUserAccount() because createUserAccount() modifies the. - // entity in-place (sets username). Using the stale $contactObjectData. - // captured before createUserAccount() would overwrite those changes. - // and could lose the organisatie field. - $contactObjectData = $contactObject->getObject(); - $contactObjectData['username'] = $user->getUID(); - - // FIX #434: Use MagicMapper::update() directly instead of. - // ObjectService::saveObject() to persist the username. This avoids:. - // 1. Having to strip the organisatie field (saveObject validates it. - // as object type but it is stored as a UUID string). - // 2. Triggering ObjectUpdatedEvent cascades that re-enter. - // processContactpersoon() with missing organisatie field, causing. - // the org lookup to fail. - // 3. Potential data loss from stale variables. - // The staged payload travels in $contactObjectData - // rather than through `setObject()`, which is not on - // ObjectEntityInterface (ADR-084). - - $this->logger->debug( - 'Saving contact with username via direct mapper update', - [ - 'app' => 'stackiq', - 'contactId' => $contactObject->getUuid(), - 'username' => $user->getUID(), - 'hasOrganisatie' => isset($contactObjectData['organization']) === true, - ] - ); + /** + * Count a ready contact person, make sure it names its organisation and + * give it a user account when it has none. + * + * @param array $run The sync run (see createOrUpdateContactPersonObject()) with the `contact` object and `wasExisting`. + * + * @return void + */ + private function completeContactPerson(array $run): void { + $contactObject = $run['contact']; + $run['stats']['contactPersonsProcessed']++; - try { - // Published contract instead of OpenRegister's Db layer. - $this->objectService->saveObject( - object: array_merge( - $contactObjectData, - ['@self' => ['organisation' => $contactObject->getOrganisation()]] - ), - register: $contactObject->getRegister(), - schema: $contactObject->getSchema(), - uuid: $contactObject->getUuid(), - silent: true, - _validation: false - ); - $this->logger->info( - 'Contact saved with username', - [ - 'app' => 'stackiq', - 'contactId' => $contactObject->getUuid(), - 'username' => $user->getUID(), - ] - ); - } catch (\Exception $saveEx) { - $this->logger->warning( - 'Failed to save username to contact object (user was created)', - [ - 'app' => 'stackiq', - 'contactId' => $contactObject->getUuid(), - 'username' => $user->getUID(), - 'error' => $saveEx->getMessage(), - ] - ); - }//end try - - // Add user to organization entity in database. - $this->contactpersonHandler->addUserToOrganizationEntity( - $contactObject, - $user->getUID(), - $organizationUuid - ); + $run['contactData'] = $this->ensureContactOrganisation(run: $run); - // Update contactpersoon object owner to user UID and organisation. - $this->updateContactPersonObjectOwner( - contactObject: $contactObject, - userUID: $user->getUID(), - register: $register, - contactSchema: $contactSchema, - organizationUuidOverride: $organizationUuid - ); + $this->logger->info( + 'βœ… CONTACT PERSON OBJECT READY', + [ + 'app' => 'stackiq', + 'organizationId' => $run['organizationUuid'], + 'contactId' => $contactObject->getUuid(), + 'email' => $run['email'], + 'wasExisting' => $run['wasExisting'], + ] + ); - $this->logger->info( - 'User account created', - [ - 'app' => 'stackiq', - 'contactId' => $contactObject->getUuid(), - 'username' => $user->getUID(), - ] - ); - }//end if + // Create user account if username is missing AND organization is active. + if (empty($run['contactData']['username']) === true) { + $this->provisionContactUser(run: $run); + } - if (empty($user) === true) { - $this->logger->error( - 'User account creation failed', - [ - 'app' => 'stackiq', - 'contactId' => $contactObject->getUuid(), - 'email' => $email, - ] - ); - $stats['errors'][] = "Failed to create user account for {$email}"; - } - }//end if + }//end completeContactPerson() - if ($organisationEntity === false || $organisationEntity->getActive() !== true) { - if ($organisationEntity !== null) { - $organizationActiveValue = $organisationEntity->getActive(); - } else { - $organizationActiveValue = false; - } + /** + * Set the organisation field on a contact person that lacks it. + * + * @param array $run The sync run with the `contact` object. + * + * @return array The contact object data, with the organisation set when it was missing. + */ + private function ensureContactOrganisation(array $run): array { + $contactObject = $run['contact']; + $contactObjectData = $contactObject->getObject(); + if (empty($contactObjectData['organization']) === true && empty($run['organizationUuid']) === false) { + $contactObjectData['organization'] = $run['organizationUuid']; + $this->persistContactObject( + contactObject: $contactObject, + object: array_merge( + $contactObjectData, + ['@self' => ['organisation' => $run['organizationUuid']]] + ) + ); + $this->logger->info( + '[FLOW] Set missing organisatie field on contact person', + [ + 'contactId' => $contactObject->getUuid(), + 'organizationUuid' => $run['organizationUuid'], + ] + ); + } - $this->logger->info( - 'Skipping user creation - organization not active or not found in entity table', - [ - 'contactId' => $contactObject->getUuid(), - 'organizationId' => $organizationUuid, - 'organizationFound' => $organisationEntity !== null, - 'organizationActive' => $organizationActiveValue, - 'email' => $email, - ] - ); - } - } catch (\Exception $e) { - // Organization not found in entity table = not active. - $this->logger->info( - 'Skipping user creation - organization not found in entity table (not active)', - [ - 'contactId' => $contactObject->getUuid(), - 'organizationId' => $organizationUuid, - 'reason' => 'Organization not found in entity table', - 'email' => $email, - ] - ); - }//end try - }//end if - }//end if + return $contactObjectData; + + }//end ensureContactOrganisation() + + /** + * Create a user account for a contact person when its organisation is active. + * + * @param array $run The sync run with the `contact` object and its `contactData`. + * + * @return void + */ + private function provisionContactUser(array $run): void { + $contactObject = $run['contact']; + $organisationEntity = $this->findOrganisationEntityForContact( + contactObject: $contactObject, + organizationUuid: $run['organizationUuid'] + ); + + try { + if ($organisationEntity !== false && $organisationEntity->getActive() === true) { + $this->createUserForContact(run: $run); + } + + if ($organisationEntity === false || $organisationEntity->getActive() !== true) { + $this->logSkippedUserCreation(organisationEntity: $organisationEntity, run: $run); + } } catch (\Exception $e) { + // Organization not found in entity table = not active. + $this->logger->info( + 'Skipping user creation - organization not found in entity table (not active)', + [ + 'contactId' => $contactObject->getUuid(), + 'organizationId' => $run['organizationUuid'], + 'reason' => 'Organization not found in entity table', + 'email' => $run['email'], + ] + ); + }//end try + + }//end provisionContactUser() + + /** + * The organisation entity of a contact person's organisation, created from + * the organisation object when the entity is missing. + * + * @param object $contactObject The contact object. + * @param string $organizationUuid The organisation uuid. + * + * @return mixed The organisation entity, or null when it is missing and cannot be created. + */ + private function findOrganisationEntityForContact(object $contactObject, string $organizationUuid): mixed { + try { + return $this->organisationMapper->findByUuid($organizationUuid); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + $this->logger->info( + 'Organisation entity missing for ' . $organizationUuid . ', creating backup entity', + [ + 'app' => 'stackiq', + 'contactId' => $contactObject->getUuid(), + ] + ); + } + + return $this->createBackupOrganisationEntity(organizationUuid: $organizationUuid); + + }//end findOrganisationEntityForContact() + + /** + * Create the organisation entity for an organisation object that has none, + * so user creation can proceed. + * + * @param string $organizationUuid The organisation uuid. + * + * @return object|null The organisation entity, or null when the object is missing or creation fails. + */ + private function createBackupOrganisationEntity(string $organizationUuid): ?object { + try { + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $orgObject = $this->objectService->find( + id: $organizationUuid, + register: ($voorzieningenConfig['register'] ?? ''), + schema: ($voorzieningenConfig['organisatie_schema'] ?? '') + ); + if (empty($orgObject) === false) { + $backupStats = [ + 'entitiesCreated' => 0, + 'entitiesUpdated' => 0, + ]; + return $this->ensureOrganisationEntity( + organisationObject: $orgObject, + stats: $backupStats + ); + } + } catch (\Exception $backupEx) { $this->logger->error( - '[FLOW] Failed to create/update contact person object', + 'Backup org entity creation failed', [ + 'app' => 'stackiq', 'organizationId' => $organizationUuid, - 'email' => ($contactData['email'] ?? $contactData['e-mailadres'] ?? 'unknown'), - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), + 'error' => $backupEx->getMessage(), ] ); - $stats['errors'][] = 'Contact person creation failed: ' . $e->getMessage(); }//end try - }//end createOrUpdateContactPersonObject() + return null; + + }//end createBackupOrganisationEntity() + + /** + * Create the user account of a contact person and link it to the contact + * and the organisation. + * + * @param array $run The sync run with the `contact` object and its `contactData`. + * + * @return void + */ + private function createUserForContact(array $run): void { + $contactObject = $run['contact']; + + // Determine if this is the first contact for the organization. + $isFirstContact = $this->contactpersonHandler->isFirstContactForOrganization( + $contactObject, + $run['contactData'] + ); + + $this->logger->info( + 'Creating user account for contact person (org is active)', + [ + 'app' => 'stackiq', + 'contactId' => $contactObject->getUuid(), + 'organizationId' => $run['organizationUuid'], + 'email' => $run['email'], + 'isFirstContact' => $isFirstContact, + ] + ); + + $user = $this->contactpersonHandler->createUserAccount($contactObject, $isFirstContact); + if (empty($user) === false) { + $run['stats']['usersCreated']++; + $this->linkCreatedUser(user: $user, run: $run); + } + + if (empty($user) === true) { + $this->logger->error( + 'User account creation failed', + [ + 'app' => 'stackiq', + 'contactId' => $contactObject->getUuid(), + 'email' => $run['email'], + ] + ); + $run['stats']['errors'][] = "Failed to create user account for {$run['email']}"; + } + + }//end createUserForContact() + + /** + * Store a new user's uid on its contact person, add the user to the + * organisation and make the user the contact's owner. + * + * @param object $user The created user. + * @param array $run The sync run with the `contact` object. + * + * @return void + */ + private function linkCreatedUser(object $user, array $run): void { + $contactObject = $run['contact']; + + // Read the contact again: createUserAccount() changes the entity in + // place, and the data read before it would overwrite those changes. + $contactObjectData = $contactObject->getObject(); + $contactObjectData['username'] = $user->getUID(); + $this->saveContactUsername(contactObject: $contactObject, contactObjectData: $contactObjectData); + + $this->contactpersonHandler->addUserToOrganizationEntity( + $contactObject, + $user->getUID(), + $run['organizationUuid'] + ); + + $this->updateContactPersonObjectOwner( + contactObject: $contactObject, + owner: [ + 'userUID' => $user->getUID(), + 'register' => $run['register'], + 'contactSchema' => $run['contactSchema'], + 'organizationUuidOverride' => $run['organizationUuid'], + ] + ); + + $this->logger->info( + 'User account created', + [ + 'app' => 'stackiq', + 'contactId' => $contactObject->getUuid(), + 'username' => $user->getUID(), + ] + ); + + }//end linkCreatedUser() + + /** + * Save a contact person with its new username, without validation or + * events, so the organisation field (a uuid where the schema expects an + * object) is kept and no update cascade re-enters the sync. + * + * @param object $contactObject The contact object. + * @param array $contactObjectData The contact data with `username` set. + * + * @return void + */ + private function saveContactUsername(object $contactObject, array $contactObjectData): void { + $this->logger->debug( + 'Saving contact with username via direct mapper update', + [ + 'app' => 'stackiq', + 'contactId' => $contactObject->getUuid(), + 'username' => $contactObjectData['username'], + 'hasOrganisatie' => isset($contactObjectData['organization']) === true, + ] + ); + + try { + $this->persistContactObject( + contactObject: $contactObject, + object: array_merge( + $contactObjectData, + ['@self' => ['organisation' => $contactObject->getOrganisation()]] + ) + ); + $this->logger->info( + 'Contact saved with username', + [ + 'app' => 'stackiq', + 'contactId' => $contactObject->getUuid(), + 'username' => $contactObjectData['username'], + ] + ); + } catch (\Exception $saveEx) { + $this->logger->warning( + 'Failed to save username to contact object (user was created)', + [ + 'app' => 'stackiq', + 'contactId' => $contactObject->getUuid(), + 'username' => $contactObjectData['username'], + 'error' => $saveEx->getMessage(), + ] + ); + }//end try + + }//end saveContactUsername() + + /** + * Log why no user account was created for a contact person. + * + * @param mixed $organisationEntity The organisation entity, or null when there is none. + * @param array $run The sync run with the `contact` object. + * + * @return void + */ + private function logSkippedUserCreation(mixed $organisationEntity, array $run): void { + $organizationActiveValue = false; + if ($organisationEntity !== null) { + $organizationActiveValue = $organisationEntity->getActive(); + } + + $this->logger->info( + 'Skipping user creation - organization not active or not found in entity table', + [ + 'contactId' => $run['contact']->getUuid(), + 'organizationId' => $run['organizationUuid'], + 'organizationFound' => $organisationEntity !== null, + 'organizationActive' => $organizationActiveValue, + 'email' => $run['email'], + ] + ); + + }//end logSkippedUserCreation() + + /** + * Save a contact person object as it is, without validation and without events. + * + * @param object $contactObject The contact object, for its register, schema and uuid. + * @param array $object The object data to save. + * + * @return void + */ + private function persistContactObject(object $contactObject, array $object): void { + // Published contract instead of OpenRegister's Db layer: the payload is + // passed explicitly, as `setObject()` is not on ObjectEntityInterface (ADR-084). + $this->objectService->saveObject( + object: $object, + register: $contactObject->getRegister(), + schema: $contactObject->getSchema(), + uuid: $contactObject->getUuid(), + silent: true, + _validation: false + ); + + }//end persistContactObject() /** * Process a specific contact person object (called from event listener) @@ -2783,13 +2915,9 @@ public function processSpecificContactPerson($contactObject): array { ); // Update contactpersoon object owner to user UID. - $this->updateContactPersonObjectOwner( - contactObject: $contactObject, - userUID: $user->getUID(), - register: $register, - contactSchema: $contactSchema, - organizationUuidOverride: $organizationUuid - ); + $owner = ['userUID' => $user->getUID(), 'register' => $register, 'contactSchema' => $contactSchema]; + $owner['organizationUuidOverride'] = $organizationUuid; + $this->updateContactPersonObjectOwner(contactObject: $contactObject, owner: $owner); $stats['usersCreated']++; }//end if @@ -3280,111 +3408,46 @@ private function updateOrganisationObjectOwner( }//end updateOrganisatieObjectOwner() /** - * Updates the contactpersoon object's @self metadata to set owner to the user UID + * Make a user the owner of a contactpersoon object and set its organisation. * * @param object $contactObject The contactpersoon object to update. - * @param string $userUID The user UID to set as owner. - * @param string $register The register ID. - * @param string $contactSchema The contact schema ID. - * @param string|null $organizationUuidOverride Optional organization UUID override. + * @param array $owner `userUID` (the new owner), `register` and `contactSchema` (logged) and the + * optional `organizationUuidOverride` (used before the object's own organisation). * * @return void */ - private function updateContactPersonObjectOwner( - object $contactObject, - string $userUID, - string $register, - string $contactSchema, - ?string $organizationUuidOverride = null, - ): void { + private function updateContactPersonObjectOwner(object $contactObject, array $owner): void { try { - $contactId = $contactObject->getUuid(); + $owner['contactId'] = $contactObject->getUuid(); + $owner['organizationUuidOverride'] = ($owner['organizationUuidOverride'] ?? null); $this->logger->info( 'OrganizationSyncService: Updating contactpersoon object owner', [ - 'contactId' => $contactId, - 'userUID' => $userUID, - 'register' => $register, - 'schema' => $contactSchema, - 'organizationUuidOverride' => $organizationUuidOverride, + 'contactId' => $owner['contactId'], + 'userUID' => $owner['userUID'], + 'register' => $owner['register'], + 'schema' => $owner['contactSchema'], + 'organizationUuidOverride' => $owner['organizationUuidOverride'], ] ); - // Get the current object data. - $currentObject = $contactObject->getObject(); - - // Get current @self metadata or create new. - $selfMetadata = ($currentObject['@self'] ?? []); - - // Update the owner field to the user UID. - $selfMetadata['owner'] = $userUID; - - // Set the organisation field in @self metadata to the organization UUID. - // Use override if provided, otherwise try to get from object data. - $orgUuid = $currentObject['organisation'] ?? $currentObject['organization'] ?? ''; - $organizationUuid = ($organizationUuidOverride ?? $orgUuid); - if (empty($organizationUuid) === false) { - $selfMetadata['organisation'] = $organizationUuid; - if (empty($organizationUuidOverride) === false) { - $sourceValue = 'override'; - } else { - $sourceValue = 'object'; - } - - $this->logger->info( - 'OrganizationSyncService: Setting @self.organisation metadata', - [ - 'contactId' => $contactId, - 'organizationUuid' => $organizationUuid, - 'source' => $sourceValue, - ] - ); - }//end if - - // Restore the organisatie field if it was removed during username save. - // This is the safety net that ensures the contactβ†’org link is never lost. - if (empty($organizationUuid) === false && empty($currentObject['organization']) === true) { - $currentObject['organization'] = $organizationUuid; - $this->logger->info( - 'OrganizationSyncService: Restored missing organisatie field on contact person', - [ - 'contactId' => $contactId, - 'organizationUuid' => $organizationUuid, - ] - ); - } - - // Update the object with the new @self metadata. - $currentObject['@self'] = $selfMetadata; - $contactObject->setObject($currentObject); - - // Also update the entity's owner and organisation fields directly. - // These are separate from the object data and control multi-tenancy. - $contactObject->setOwner($userUID); - if (empty($organizationUuid) === false) { - $contactObject->setOrganisation($organizationUuid); - } + $selfMetadata = $this->stageContactOwner(contactObject: $contactObject, owner: $owner); - // Save using MagicMapper directly to bypass validation and ensure metadata is persisted. - // Published contract instead of OpenRegister's Db layer. - $this->objectService->saveObject( + // Save directly, bypassing validation, so the metadata is persisted. + $this->persistContactObject( + contactObject: $contactObject, object: array_merge( $contactObject->getObject(), ['@self' => ['organisation' => $contactObject->getOrganisation()]] - ), - register: $contactObject->getRegister(), - schema: $contactObject->getSchema(), - uuid: $contactObject->getUuid(), - silent: true, - _validation: false + ) ); $this->logger->info( 'OrganizationSyncService: Successfully updated contactpersoon object owner and organisation', [ - 'contactId' => $contactId, - 'userUID' => $userUID, + 'contactId' => $owner['contactId'], + 'userUID' => $owner['userUID'], 'ownerSet' => $selfMetadata['owner'], 'organisationSet' => ($selfMetadata['organisation'] ?? 'not set'), ] @@ -3394,7 +3457,7 @@ private function updateContactPersonObjectOwner( 'OrganizationSyncService: Failed to update contactpersoon object owner', [ 'contactId' => $contactObject->getUuid(), - 'userUID' => $userUID, + 'userUID' => $owner['userUID'], 'exception' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), @@ -3402,5 +3465,83 @@ private function updateContactPersonObjectOwner( ); }//end try - }//end updateContactpersoonObjectOwner() + }//end updateContactPersonObjectOwner() + + /** + * Set the owner and organisation on a contactpersoon entity: in its @self + * metadata, in its organisation field when that is empty, and on the entity. + * + * @param object $contactObject The contactpersoon object. + * @param array $owner `contactId`, `userUID` and `organizationUuidOverride`. + * + * @return array The @self metadata that was set. + */ + private function stageContactOwner(object $contactObject, array $owner): array { + $currentObject = $contactObject->getObject(); + + $selfMetadata = ($currentObject['@self'] ?? []); + $selfMetadata['owner'] = $owner['userUID']; + + $organizationUuid = $this->ownerOrganisation(currentObject: $currentObject, owner: $owner); + if (empty($organizationUuid) === false) { + $selfMetadata['organisation'] = $organizationUuid; + } + + // Restore the organisatie field if it was removed during username save. + // This is the safety net that ensures the contactβ†’org link is never lost. + if (empty($organizationUuid) === false && empty($currentObject['organization']) === true) { + $currentObject['organization'] = $organizationUuid; + $this->logger->info( + 'OrganizationSyncService: Restored missing organisatie field on contact person', + [ + 'contactId' => $owner['contactId'], + 'organizationUuid' => $organizationUuid, + ] + ); + } + + $currentObject['@self'] = $selfMetadata; + $contactObject->setObject($currentObject); + + // The entity's owner and organisation fields control multi-tenancy. + $contactObject->setOwner($owner['userUID']); + if (empty($organizationUuid) === false) { + $contactObject->setOrganisation($organizationUuid); + } + + return $selfMetadata; + + }//end stageContactOwner() + + /** + * The organisation a contactpersoon's owner belongs to: the override, else + * the object's own organisation field. + * + * @param array $currentObject The contactpersoon object data. + * @param array $owner `contactId` and `organizationUuidOverride`. + * + * @return mixed The organisation uuid, empty when there is none. + */ + private function ownerOrganisation(array $currentObject, array $owner): mixed { + $orgUuid = $currentObject['organisation'] ?? $currentObject['organization'] ?? ''; + $organizationUuid = ($owner['organizationUuidOverride'] ?? $orgUuid); + if (empty($organizationUuid) === false) { + $sourceValue = 'object'; + if (empty($owner['organizationUuidOverride']) === false) { + $sourceValue = 'override'; + } + + $this->logger->info( + 'OrganizationSyncService: Setting @self.organisation metadata', + [ + 'contactId' => $owner['contactId'], + 'organizationUuid' => $organizationUuid, + 'source' => $sourceValue, + ] + ); + } + + return $organizationUuid; + + }//end ownerOrganisation() }//end class diff --git a/tests/Unit/Service/OrganizationSyncContactPersonGoldenTest.php b/tests/Unit/Service/OrganizationSyncContactPersonGoldenTest.php new file mode 100644 index 000000000..53f62cdd3 --- /dev/null +++ b/tests/Unit/Service/OrganizationSyncContactPersonGoldenTest.php @@ -0,0 +1,733 @@ + + * @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 contact person object sync. + * + * @covers \OCA\Stackiq\Service\OrganizationSyncService + * + * @spec openspec/specs/organization-sync/spec.md + */ +class OrganizationSyncContactPersonGoldenTest 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/organizationSyncContactPerson.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('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->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: contact data, organisation uuid and the doubles' answers. + * + * @return array + */ + public static function contactCases(): array { + return [ + 'no email' => [['voornaam' => 'A'], 'org-1', fn ($t) => []], + 'new contact, active org, user created' => [ + ['e-mailadres' => 'a@x.nl', 'voornaam' => 'A', 'achternaam' => 'B'], + 'org-1', + fn ($t) => [ + 'save' => [$t->entity(['email' => 'a@x.nl'], 'c-1'), null, null, null], + 'org' => [self::organisation(active: true)], + 'first' => true, + 'user' => $t->user('u-1'), + ], + ], + 'existing contact with org and username' => [ + ['email' => 'b@x.nl', 'id' => 'c-2'], + 'org-1', + fn ($t) => ['find' => [$t->entity(['organization' => 'org-9', 'username' => 'bee'], 'c-2')]], + ], + 'uuid fetch fails, create restores org' => [ + ['email' => 'c@x.nl', 'uuid' => 'c-3', 'organization' => 'org-3', 'id' => ''], + 'org-3', + fn ($t) => [ + 'find' => [new \RuntimeException('gone')], + 'save' => [$t->entity(['email' => 'c@x.nl', 'username' => 'cee'], 'c-3b'), null], + ], + ], + 'org entity missing, backup finds none' => [ + ['email' => 'd@x.nl', 'id' => 'c-4'], + 'org-4', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-4'], 'c-4'), null], + 'org' => [new DoesNotExistException('none')], + ], + ], + 'org entity missing, backup lookup fails' => [ + ['email' => 'e@x.nl', 'id' => 'c-5'], + 'org-5', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-5'], 'c-5')], + 'org' => [new DoesNotExistException('none')], + 'config' => new \RuntimeException('no config'), + ], + ], + 'org entity missing, backup builds one' => [ + ['email' => 'f@x.nl', 'id' => 'c-6'], + 'org-6', + fn ($t) => [ + 'find' => [ + $t->entity(['organization' => 'org-6'], 'c-6'), + $t->entity(['naam' => 'Org six'], 'org-6'), + new \RuntimeException('full fetch fails'), + ], + 'org' => [new DoesNotExistException('none'), new DoesNotExistException('none')], + ], + ], + 'org inactive' => [ + ['email' => 'g@x.nl', 'id' => 'c-7'], + 'org-7', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-7'], 'c-7')], + 'org' => [self::organisation(active: false)], + ], + ], + 'org active is not true' => [ + ['email' => 'g2@x.nl', 'id' => 'c-7b'], + 'org-7', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-7'], 'c-7b')], + 'org' => [self::organisation(active: 1)], + ], + ], + 'org active check throws' => [ + ['email' => 'h@x.nl', 'id' => 'c-8'], + 'org-8', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-8'], 'c-8')], + 'org' => [self::organisation(active: new \RuntimeException('db down'))], + ], + ], + 'org lookup throws generic' => [ + ['email' => 'i@x.nl', 'id' => 'c-9'], + 'org-9', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-9'], 'c-9')], + 'org' => [new \RuntimeException('mapper broke')], + ], + ], + 'user creation fails' => [ + ['email' => 'j@x.nl', 'id' => 'c-10'], + 'org-10', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-10'], 'c-10')], + 'org' => [self::organisation(active: true)], + ], + ], + 'username save fails' => [ + ['email' => 'k@x.nl', 'id' => 'c-11'], + '', + fn ($t) => [ + 'find' => [$t->entity(['organisation' => 'org-11b'], 'c-11')], + 'org' => [self::organisation(active: true)], + 'save' => [new \RuntimeException('save broke'), null], + 'user' => $t->user('u-11'), + ], + ], + 'owner update save fails' => [ + ['email' => 'l@x.nl', 'id' => 'c-12'], + 'org-12', + fn ($t) => [ + 'find' => [$t->entity(['organization' => 'org-12', '@self' => ['owner' => 'old', 'x' => 1]], 'c-12')], + 'org' => [self::organisation(active: true)], + 'save' => [null, new \RuntimeException('owner save broke')], + 'user' => $t->user('u-12'), + ], + ], + 'create fails' => [ + ['email' => 'm@x.nl'], + 'org-13', + fn ($t) => ['save' => [new \RuntimeException('create broke')]], + ], + 'no org uuid and none on contact' => [ + ['email' => 'n@x.nl', 'id' => 'c-14'], + '', + fn ($t) => [ + 'find' => [$t->entity([], 'c-14')], + 'org' => [self::organisation(active: true)], + 'user' => $t->user('u-14'), + ], + ], + ]; + + }//end contactCases() + + /** + * Run one case of createOrUpdateContactPersonObject and compare it with the golden file. + * + * @param array $contactData The contact data. + * @param string $organizationUuid The organisation uuid. + * @param \Closure $plan The doubles' answers. + * + * @dataProvider contactCases + * + * @return void + */ + public function testContactPerson(array $contactData, string $organizationUuid, \Closure $plan): void { + $this->plan = array_merge(['find' => [], 'save' => [], 'org' => []], $plan($this)); + $service = $this->service(); + $stats = ['contactPersonsProcessed' => 0, 'usersCreated' => 0, 'errors' => []]; + $escaped = null; + try { + $this->callContactPerson(service: $service, contactData: $contactData, organizationUuid: $organizationUuid, stats: $stats); + } catch (\Throwable $e) { + $escaped = get_class($e) . ': ' . $e->getMessage(); + } + + $this->check( + key: 'contact: ' . $this->dataName(), + value: ['trace' => $this->trace, 'stats' => $stats, 'escaped' => $escaped] + ); + + }//end testContactPerson() + + /** + * The owner-update cases: object data, user, register, schema and override. + * + * @return array + */ + public static function ownerCases(): array { + return [ + 'override set' => [['organization' => 'org-a', '@self' => ['owner' => 'x']], ['u-1', 'r', 's', 'org-o'], []], + 'organisation from object' => [['organisation' => 'org-b'], ['u-2', 'r', 's', null], []], + 'organization from object' => [['organization' => 'org-c'], ['u-3', 'r', 's'], []], + 'empty override falls back' => [['organization' => 'org-d'], ['u-4', 'r', 's', ''], []], + 'no organisation anywhere' => [[], ['u-5', 'r', 's', null], []], + 'override fills missing org' => [['organization' => ''], ['u-6', 'r', 's', 'org-f'], []], + 'save fails' => [['organization' => 'org-g'], ['u-7', 'r', 's', null], [new \RuntimeException('nope')]], + ]; + + }//end ownerCases() + + /** + * Run one case of updateContactPersonObjectOwner and compare it with the golden file. + * + * @param array $object The contact object data. + * @param array $args userUID, register, schema and, when given, the override. + * @param array $save The save answers. + * + * @dataProvider ownerCases + * + * @return void + */ + public function testOwnerUpdate(array $object, array $args, array $save): void { + $this->plan = ['find' => [], 'save' => $save, 'org' => []]; + $service = $this->service(); + $contact = $this->entity(object: $object, uuid: 'c-owner'); + $escaped = null; + try { + $this->callOwnerUpdate(service: $service, contact: $contact, args: $args); + } catch (\Throwable $e) { + $escaped = get_class($e) . ': ' . $e->getMessage(); + } + + $this->check( + key: 'owner: ' . $this->dataName(), + value: ['trace' => $this->trace, 'owner' => $contact->getOwner(), 'organisation' => $contact->getOrganisation(), 'escaped' => $escaped] + ); + + }//end testOwnerUpdate() + + /** + * Call createOrUpdateContactPersonObject through reflection. + * + * @param OrganizationSyncService $service The service. + * @param array $contactData The contact data. + * @param string $organizationUuid The organisation uuid. + * @param array $stats The stats. + * + * @return void + */ + private function callContactPerson(OrganizationSyncService $service, array $contactData, string $organizationUuid, array &$stats): void { + $method = new \ReflectionMethod($service, 'createOrUpdateContactPersonObject'); + $method->setAccessible(true); + $run = [ + 'organizationUuid' => $organizationUuid, + 'register' => 'reg-1', + 'contactSchema' => 'contact-schema', + 'stats' => &$stats, + ]; + $method->invokeArgs($service, [$contactData, $run]); + + }//end callContactPerson() + + /** + * Call updateContactPersonObjectOwner through reflection. + * + * @param OrganizationSyncService $service The service. + * @param ObjectEntityInterface $contact The contact. + * @param array $args userUID, register, schema and, when given, the override. + * + * @return void + */ + private function callOwnerUpdate(OrganizationSyncService $service, ObjectEntityInterface $contact, array $args): void { + $method = new \ReflectionMethod($service, 'updateContactPersonObjectOwner'); + $method->setAccessible(true); + $owner = [ + 'userUID' => $args[0], + 'register' => $args[1], + 'contactSchema' => $args[2], + ]; + if (array_key_exists(3, $args) === true) { + $owner['organizationUuidOverride'] = $args[3]; + } + + $method->invokeArgs($service, [$contact, $owner]); + + }//end callOwnerUpdate() + + /** + * 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/organizationSyncContactPerson.json b/tests/Unit/Service/golden/organizationSyncContactPerson.json new file mode 100644 index 000000000..82000049b --- /dev/null +++ b/tests/Unit/Service/golden/organizationSyncContactPerson.json @@ -0,0 +1,2127 @@ +{ + "contact: no email": { + "trace": [ + [ + "log.warning", + "[FLOW] Contact person has no email, skipping", + { + "organizationId": "org-1", + "contactData": [ + "voornaam" + ] + } + ] + ], + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "errors": [] + }, + "escaped": null + }, + "contact: new contact, active org, user created": { + "trace": [ + [ + "log.info", + "πŸ“§ CREATING NEW CONTACT PERSON OBJECT", + { + "app": "stackiq", + "organizationId": "org-1", + "email": "a@x.nl", + "name": "A B" + } + ], + [ + "objects.saveObject", + { + "object": { + "e-mailadres": "a@x.nl", + "voornaam": "A", + "achternaam": "B" + }, + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "a@x.nl", + "organization": "org-1", + "@self": { + "organisation": "org-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on contact person", + { + "contactId": "c-1", + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-1", + "contactId": "c-1", + "email": "a@x.nl", + "wasExisting": false + } + ], + [ + "mapper.findByUuid", + "org-1" + ], + [ + "handler.isFirst", + "c-1", + { + "email": "a@x.nl", + "organization": "org-1" + } + ], + [ + "log.info", + "Creating user account for contact person (org is active)", + { + "app": "stackiq", + "contactId": "c-1", + "organizationId": "org-1", + "email": "a@x.nl", + "isFirstContact": true + } + ], + [ + "handler.createUserAccount", + "c-1", + true + ], + [ + "log.debug", + "Saving contact with username via direct mapper update", + { + "app": "stackiq", + "contactId": "c-1", + "username": "u-1", + "hasOrganisatie": false + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "a@x.nl", + "username": "u-1", + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "Contact saved with username", + { + "app": "stackiq", + "contactId": "c-1", + "username": "u-1" + } + ], + [ + "handler.addUserToOrganizationEntity", + [ + "c-1", + "u-1", + "org-1" + ] + ], + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-1", + "userUID": "u-1", + "register": "reg-1", + "schema": "contact-schema", + "organizationUuidOverride": "org-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Setting @self.organisation metadata", + { + "contactId": "c-1", + "organizationUuid": "org-1", + "source": "override" + } + ], + [ + "log.info", + "OrganizationSyncService: Restored missing organisatie field on contact person", + { + "contactId": "c-1", + "organizationUuid": "org-1" + } + ], + [ + "entity.setObject", + "c-1", + { + "email": "a@x.nl", + "organization": "org-1", + "@self": { + "owner": "u-1", + "organisation": "org-1" + } + } + ], + [ + "entity.setOwner", + "c-1", + "u-1" + ], + [ + "entity.setOrganisation", + "c-1", + "org-1" + ], + [ + "objects.saveObject", + { + "object": { + "email": "a@x.nl", + "organization": "org-1", + "@self": { + "organisation": "org-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-1", + "userUID": "u-1", + "ownerSet": "u-1", + "organisationSet": "org-1" + } + ], + [ + "log.info", + "User account created", + { + "app": "stackiq", + "contactId": "c-1", + "username": "u-1" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 1, + "errors": [] + }, + "escaped": null + }, + "contact: existing contact with org and username": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-1", + "contactId": "c-2", + "email": "b@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-2", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-1", + "contactId": "c-2", + "email": "b@x.nl", + "wasExisting": true + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [] + }, + "escaped": null + }, + "contact: uuid fetch fails, create restores org": { + "trace": [ + [ + "log.info", + "πŸ“§ CREATING NEW CONTACT PERSON OBJECT", + { + "app": "stackiq", + "organizationId": "org-3", + "email": "c@x.nl", + "name": " " + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "c@x.nl" + }, + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "c@x.nl", + "username": "cee", + "organization": "org-3", + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-3b", + "silent": true, + "_validation": false + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "c@x.nl", + "username": "cee", + "organization": "org-3", + "@self": { + "organisation": "org-3" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-3b", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on contact person", + { + "contactId": "c-3b", + "organizationUuid": "org-3" + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-3", + "contactId": "c-3b", + "email": "c@x.nl", + "wasExisting": false + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [] + }, + "escaped": null + }, + "contact: org entity missing, backup finds none": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-4", + "contactId": "c-4", + "email": "d@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-4", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-4", + "contactId": "c-4", + "email": "d@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-4" + ], + [ + "log.info", + "Organisation entity missing for org-4, creating backup entity", + { + "app": "stackiq", + "contactId": "c-4" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-4", + "register": "reg-v", + "schema": "org-schema" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [] + }, + "escaped": "Error: Call to a member function getActive() on null" + }, + "contact: org entity missing, backup lookup fails": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-5", + "contactId": "c-5", + "email": "e@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-5", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-5", + "contactId": "c-5", + "email": "e@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-5" + ], + [ + "log.info", + "Organisation entity missing for org-5, creating backup entity", + { + "app": "stackiq", + "contactId": "c-5" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.error", + "Backup org entity creation failed", + { + "app": "stackiq", + "organizationId": "org-5", + "error": "no config" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [] + }, + "escaped": "Error: Call to a member function getActive() on null" + }, + "contact: org entity missing, backup builds one": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-6", + "contactId": "c-6", + "email": "f@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-6", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-6", + "contactId": "c-6", + "email": "f@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-6" + ], + [ + "log.info", + "Organisation entity missing for org-6, creating backup entity", + { + "app": "stackiq", + "contactId": "c-6" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-6", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "objects.find", + { + "id": "org-6", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "Could not fetch full organisation object, using provided object", + { + "organisatieId": "org-6", + "error": "full fetch fails" + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "org-6", + "name": "Unknown", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "org-6" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "org-6", + "name": "Unknown" + } + ], + [ + "organisations.create", + { + "naam": "Org six" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "org-6", + "entityId": null, + "active": true, + "name": "Org" + } + ], + [ + "log.info", + "[FLOW] Sending organization registration email", + { + "organisatieId": "org-6" + } + ], + [ + "log.info", + "πŸ“§ Organization registration email not sent (disabled or not configured)", + { + "organisatieId": "org-6" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "org-6", + "organisationEntityUuid": "", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "org-6", + { + "naam": "Org six", + "organisation": "", + "@self": { + "owner": "", + "organisation": "" + } + } + ], + [ + "entity.setOwner", + "org-6", + "" + ], + [ + "entity.setOrganisation", + "org-6", + "" + ], + [ + "objects.saveObject", + { + "object": { + "naam": "Org six", + "organisation": "", + "@self": { + "organisation": "" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "org-6", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "org-6", + "organisationEntityUuid": "", + "ownerSet": "", + "organisationSet": "" + } + ], + [ + "handler.isFirst", + "c-6", + { + "organization": "org-6" + } + ], + [ + "log.info", + "Creating user account for contact person (org is active)", + { + "app": "stackiq", + "contactId": "c-6", + "organizationId": "org-6", + "email": "f@x.nl", + "isFirstContact": false + } + ], + [ + "handler.createUserAccount", + "c-6", + false + ], + [ + "log.error", + "User account creation failed", + { + "app": "stackiq", + "contactId": "c-6", + "email": "f@x.nl" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [ + "Failed to create user account for f@x.nl" + ] + }, + "escaped": null + }, + "contact: org inactive": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-7", + "contactId": "c-7", + "email": "g@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-7", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-7", + "contactId": "c-7", + "email": "g@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-7" + ], + [ + "log.info", + "Skipping user creation - organization not active or not found in entity table", + { + "contactId": "c-7", + "organizationId": "org-7", + "organizationFound": true, + "organizationActive": false, + "email": "g@x.nl" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [] + }, + "escaped": null + }, + "contact: org active is not true": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-7", + "contactId": "c-7b", + "email": "g2@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-7b", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-7", + "contactId": "c-7b", + "email": "g2@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-7" + ], + [ + "log.info", + "Skipping user creation - organization not active or not found in entity table", + { + "contactId": "c-7b", + "organizationId": "org-7", + "organizationFound": true, + "organizationActive": 1, + "email": "g2@x.nl" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [] + }, + "escaped": null + }, + "contact: org active check throws": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-8", + "contactId": "c-8", + "email": "h@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-8", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-8", + "contactId": "c-8", + "email": "h@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-8" + ], + [ + "log.info", + "Skipping user creation - organization not found in entity table (not active)", + { + "contactId": "c-8", + "organizationId": "org-8", + "reason": "Organization not found in entity table", + "email": "h@x.nl" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [] + }, + "escaped": null + }, + "contact: org lookup throws generic": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-9", + "contactId": "c-9", + "email": "i@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-9", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-9", + "contactId": "c-9", + "email": "i@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-9" + ], + [ + "log.error", + "[FLOW] Failed to create/update contact person object", + { + "organizationId": "org-9", + "email": "i@x.nl", + "exception": "mapper broke", + "trace": "(trace)" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [ + "Contact person creation failed: mapper broke" + ] + }, + "escaped": null + }, + "contact: user creation fails": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-10", + "contactId": "c-10", + "email": "j@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-10", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-10", + "contactId": "c-10", + "email": "j@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-10" + ], + [ + "handler.isFirst", + "c-10", + { + "organization": "org-10" + } + ], + [ + "log.info", + "Creating user account for contact person (org is active)", + { + "app": "stackiq", + "contactId": "c-10", + "organizationId": "org-10", + "email": "j@x.nl", + "isFirstContact": false + } + ], + [ + "handler.createUserAccount", + "c-10", + false + ], + [ + "log.error", + "User account creation failed", + { + "app": "stackiq", + "contactId": "c-10", + "email": "j@x.nl" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "errors": [ + "Failed to create user account for j@x.nl" + ] + }, + "escaped": null + }, + "contact: username save fails": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "", + "contactId": "c-11", + "email": "k@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-11", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "", + "contactId": "c-11", + "email": "k@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "" + ], + [ + "handler.isFirst", + "c-11", + { + "organisation": "org-11b" + } + ], + [ + "log.info", + "Creating user account for contact person (org is active)", + { + "app": "stackiq", + "contactId": "c-11", + "organizationId": "", + "email": "k@x.nl", + "isFirstContact": false + } + ], + [ + "handler.createUserAccount", + "c-11", + false + ], + [ + "log.debug", + "Saving contact with username via direct mapper update", + { + "app": "stackiq", + "contactId": "c-11", + "username": "u-11", + "hasOrganisatie": false + } + ], + [ + "objects.saveObject", + { + "object": { + "organisation": "org-11b", + "username": "u-11", + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-11", + "silent": true, + "_validation": false + } + ], + [ + "log.warning", + "Failed to save username to contact object (user was created)", + { + "app": "stackiq", + "contactId": "c-11", + "username": "u-11", + "error": "save broke" + } + ], + [ + "handler.addUserToOrganizationEntity", + [ + "c-11", + "u-11", + "" + ] + ], + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-11", + "userUID": "u-11", + "register": "reg-1", + "schema": "contact-schema", + "organizationUuidOverride": "" + } + ], + [ + "entity.setObject", + "c-11", + { + "organisation": "org-11b", + "@self": { + "owner": "u-11" + } + } + ], + [ + "entity.setOwner", + "c-11", + "u-11" + ], + [ + "objects.saveObject", + { + "object": { + "organisation": "org-11b", + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-11", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-11", + "userUID": "u-11", + "ownerSet": "u-11", + "organisationSet": "not set" + } + ], + [ + "log.info", + "User account created", + { + "app": "stackiq", + "contactId": "c-11", + "username": "u-11" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 1, + "errors": [] + }, + "escaped": null + }, + "contact: owner update save fails": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "org-12", + "contactId": "c-12", + "email": "l@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-12", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "org-12", + "contactId": "c-12", + "email": "l@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "org-12" + ], + [ + "handler.isFirst", + "c-12", + { + "organization": "org-12", + "@self": { + "owner": "old", + "x": 1 + } + } + ], + [ + "log.info", + "Creating user account for contact person (org is active)", + { + "app": "stackiq", + "contactId": "c-12", + "organizationId": "org-12", + "email": "l@x.nl", + "isFirstContact": false + } + ], + [ + "handler.createUserAccount", + "c-12", + false + ], + [ + "log.debug", + "Saving contact with username via direct mapper update", + { + "app": "stackiq", + "contactId": "c-12", + "username": "u-12", + "hasOrganisatie": true + } + ], + [ + "objects.saveObject", + { + "object": { + "organization": "org-12", + "@self": { + "organisation": "org-of-entity" + }, + "username": "u-12" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-12", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "Contact saved with username", + { + "app": "stackiq", + "contactId": "c-12", + "username": "u-12" + } + ], + [ + "handler.addUserToOrganizationEntity", + [ + "c-12", + "u-12", + "org-12" + ] + ], + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-12", + "userUID": "u-12", + "register": "reg-1", + "schema": "contact-schema", + "organizationUuidOverride": "org-12" + } + ], + [ + "log.info", + "OrganizationSyncService: Setting @self.organisation metadata", + { + "contactId": "c-12", + "organizationUuid": "org-12", + "source": "override" + } + ], + [ + "entity.setObject", + "c-12", + { + "organization": "org-12", + "@self": { + "owner": "u-12", + "x": 1, + "organisation": "org-12" + } + } + ], + [ + "entity.setOwner", + "c-12", + "u-12" + ], + [ + "entity.setOrganisation", + "c-12", + "org-12" + ], + [ + "objects.saveObject", + { + "object": { + "organization": "org-12", + "@self": { + "organisation": "org-12" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-12", + "silent": true, + "_validation": false + } + ], + [ + "log.error", + "OrganizationSyncService: Failed to update contactpersoon object owner", + { + "contactId": "c-12", + "userUID": "u-12", + "exception": "owner save broke", + "file": "(file)", + "line": "(line)" + } + ], + [ + "log.info", + "User account created", + { + "app": "stackiq", + "contactId": "c-12", + "username": "u-12" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 1, + "errors": [] + }, + "escaped": null + }, + "contact: create fails": { + "trace": [ + [ + "log.info", + "πŸ“§ CREATING NEW CONTACT PERSON OBJECT", + { + "app": "stackiq", + "organizationId": "org-13", + "email": "m@x.nl", + "name": " " + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "m@x.nl" + }, + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.error", + "[FLOW] Failed to create/update contact person object", + { + "organizationId": "org-13", + "email": "m@x.nl", + "exception": "create broke", + "trace": "(trace)" + } + ] + ], + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "errors": [ + "Contact person creation failed: create broke" + ] + }, + "escaped": null + }, + "contact: no org uuid and none on contact": { + "trace": [ + [ + "log.info", + "πŸ“§ FETCHING EXISTING CONTACT PERSON", + { + "app": "stackiq", + "organizationId": "", + "contactId": "c-14", + "email": "n@x.nl" + } + ], + [ + "objects.find", + { + "id": "c-14", + "register": "reg-1", + "schema": "contact-schema", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "βœ… CONTACT PERSON OBJECT READY", + { + "app": "stackiq", + "organizationId": "", + "contactId": "c-14", + "email": "n@x.nl", + "wasExisting": true + } + ], + [ + "mapper.findByUuid", + "" + ], + [ + "handler.isFirst", + "c-14", + [] + ], + [ + "log.info", + "Creating user account for contact person (org is active)", + { + "app": "stackiq", + "contactId": "c-14", + "organizationId": "", + "email": "n@x.nl", + "isFirstContact": false + } + ], + [ + "handler.createUserAccount", + "c-14", + false + ], + [ + "log.debug", + "Saving contact with username via direct mapper update", + { + "app": "stackiq", + "contactId": "c-14", + "username": "u-14", + "hasOrganisatie": false + } + ], + [ + "objects.saveObject", + { + "object": { + "username": "u-14", + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-14", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "Contact saved with username", + { + "app": "stackiq", + "contactId": "c-14", + "username": "u-14" + } + ], + [ + "handler.addUserToOrganizationEntity", + [ + "c-14", + "u-14", + "" + ] + ], + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-14", + "userUID": "u-14", + "register": "reg-1", + "schema": "contact-schema", + "organizationUuidOverride": "" + } + ], + [ + "entity.setObject", + "c-14", + { + "@self": { + "owner": "u-14" + } + } + ], + [ + "entity.setOwner", + "c-14", + "u-14" + ], + [ + "objects.saveObject", + { + "object": { + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-14", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-14", + "userUID": "u-14", + "ownerSet": "u-14", + "organisationSet": "not set" + } + ], + [ + "log.info", + "User account created", + { + "app": "stackiq", + "contactId": "c-14", + "username": "u-14" + } + ] + ], + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 1, + "errors": [] + }, + "escaped": null + }, + "owner: override set": { + "trace": [ + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-1", + "register": "r", + "schema": "s", + "organizationUuidOverride": "org-o" + } + ], + [ + "log.info", + "OrganizationSyncService: Setting @self.organisation metadata", + { + "contactId": "c-owner", + "organizationUuid": "org-o", + "source": "override" + } + ], + [ + "entity.setObject", + "c-owner", + { + "organization": "org-a", + "@self": { + "owner": "u-1", + "organisation": "org-o" + } + } + ], + [ + "entity.setOwner", + "c-owner", + "u-1" + ], + [ + "entity.setOrganisation", + "c-owner", + "org-o" + ], + [ + "objects.saveObject", + { + "object": { + "organization": "org-a", + "@self": { + "organisation": "org-o" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-owner", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-owner", + "userUID": "u-1", + "ownerSet": "u-1", + "organisationSet": "org-o" + } + ] + ], + "owner": "u-1", + "organisation": "org-o", + "escaped": null + }, + "owner: organisation from object": { + "trace": [ + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-2", + "register": "r", + "schema": "s", + "organizationUuidOverride": null + } + ], + [ + "log.info", + "OrganizationSyncService: Setting @self.organisation metadata", + { + "contactId": "c-owner", + "organizationUuid": "org-b", + "source": "object" + } + ], + [ + "log.info", + "OrganizationSyncService: Restored missing organisatie field on contact person", + { + "contactId": "c-owner", + "organizationUuid": "org-b" + } + ], + [ + "entity.setObject", + "c-owner", + { + "organisation": "org-b", + "organization": "org-b", + "@self": { + "owner": "u-2", + "organisation": "org-b" + } + } + ], + [ + "entity.setOwner", + "c-owner", + "u-2" + ], + [ + "entity.setOrganisation", + "c-owner", + "org-b" + ], + [ + "objects.saveObject", + { + "object": { + "organisation": "org-b", + "organization": "org-b", + "@self": { + "organisation": "org-b" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-owner", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-owner", + "userUID": "u-2", + "ownerSet": "u-2", + "organisationSet": "org-b" + } + ] + ], + "owner": "u-2", + "organisation": "org-b", + "escaped": null + }, + "owner: organization from object": { + "trace": [ + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-3", + "register": "r", + "schema": "s", + "organizationUuidOverride": null + } + ], + [ + "log.info", + "OrganizationSyncService: Setting @self.organisation metadata", + { + "contactId": "c-owner", + "organizationUuid": "org-c", + "source": "object" + } + ], + [ + "entity.setObject", + "c-owner", + { + "organization": "org-c", + "@self": { + "owner": "u-3", + "organisation": "org-c" + } + } + ], + [ + "entity.setOwner", + "c-owner", + "u-3" + ], + [ + "entity.setOrganisation", + "c-owner", + "org-c" + ], + [ + "objects.saveObject", + { + "object": { + "organization": "org-c", + "@self": { + "organisation": "org-c" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-owner", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-owner", + "userUID": "u-3", + "ownerSet": "u-3", + "organisationSet": "org-c" + } + ] + ], + "owner": "u-3", + "organisation": "org-c", + "escaped": null + }, + "owner: empty override falls back": { + "trace": [ + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-4", + "register": "r", + "schema": "s", + "organizationUuidOverride": "" + } + ], + [ + "entity.setObject", + "c-owner", + { + "organization": "org-d", + "@self": { + "owner": "u-4" + } + } + ], + [ + "entity.setOwner", + "c-owner", + "u-4" + ], + [ + "objects.saveObject", + { + "object": { + "organization": "org-d", + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-owner", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-owner", + "userUID": "u-4", + "ownerSet": "u-4", + "organisationSet": "not set" + } + ] + ], + "owner": "u-4", + "organisation": "org-of-entity", + "escaped": null + }, + "owner: no organisation anywhere": { + "trace": [ + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-5", + "register": "r", + "schema": "s", + "organizationUuidOverride": null + } + ], + [ + "entity.setObject", + "c-owner", + { + "@self": { + "owner": "u-5" + } + } + ], + [ + "entity.setOwner", + "c-owner", + "u-5" + ], + [ + "objects.saveObject", + { + "object": { + "@self": { + "organisation": "org-of-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-owner", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-owner", + "userUID": "u-5", + "ownerSet": "u-5", + "organisationSet": "not set" + } + ] + ], + "owner": "u-5", + "organisation": "org-of-entity", + "escaped": null + }, + "owner: override fills missing org": { + "trace": [ + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-6", + "register": "r", + "schema": "s", + "organizationUuidOverride": "org-f" + } + ], + [ + "log.info", + "OrganizationSyncService: Setting @self.organisation metadata", + { + "contactId": "c-owner", + "organizationUuid": "org-f", + "source": "override" + } + ], + [ + "log.info", + "OrganizationSyncService: Restored missing organisatie field on contact person", + { + "contactId": "c-owner", + "organizationUuid": "org-f" + } + ], + [ + "entity.setObject", + "c-owner", + { + "organization": "org-f", + "@self": { + "owner": "u-6", + "organisation": "org-f" + } + } + ], + [ + "entity.setOwner", + "c-owner", + "u-6" + ], + [ + "entity.setOrganisation", + "c-owner", + "org-f" + ], + [ + "objects.saveObject", + { + "object": { + "organization": "org-f", + "@self": { + "organisation": "org-f" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-owner", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated contactpersoon object owner and organisation", + { + "contactId": "c-owner", + "userUID": "u-6", + "ownerSet": "u-6", + "organisationSet": "org-f" + } + ] + ], + "owner": "u-6", + "organisation": "org-f", + "escaped": null + }, + "owner: save fails": { + "trace": [ + [ + "log.info", + "OrganizationSyncService: Updating contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-7", + "register": "r", + "schema": "s", + "organizationUuidOverride": null + } + ], + [ + "log.info", + "OrganizationSyncService: Setting @self.organisation metadata", + { + "contactId": "c-owner", + "organizationUuid": "org-g", + "source": "object" + } + ], + [ + "entity.setObject", + "c-owner", + { + "organization": "org-g", + "@self": { + "owner": "u-7", + "organisation": "org-g" + } + } + ], + [ + "entity.setOwner", + "c-owner", + "u-7" + ], + [ + "entity.setOrganisation", + "c-owner", + "org-g" + ], + [ + "objects.saveObject", + { + "object": { + "organization": "org-g", + "@self": { + "organisation": "org-g" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-owner", + "silent": true, + "_validation": false + } + ], + [ + "log.error", + "OrganizationSyncService: Failed to update contactpersoon object owner", + { + "contactId": "c-owner", + "userUID": "u-7", + "exception": "nope", + "file": "(file)", + "line": "(line)" + } + ] + ], + "owner": "u-7", + "organisation": "org-g", + "escaped": null + } +}