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 + } +}