diff --git a/README.md b/README.md index 4a9e5b4d..4816855f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ To install the Software Catalogus app, follow these steps: ### šŸ‘„ Advanced Group Management - **Role-Based Groups**: Automatic assignment to groups based on user roles (beheerder, inkoper) - **Organization Groups**: Each organization gets its own group with automatic member assignment -- **Special Groups**: Municipality (gemeente) users automatically get 'ambtenaar' group +- **Special Groups**: The 'ambtenaar' group is available for manual assignment (no longer automatically assigned) - **Dynamic Updates**: Group memberships automatically updated when roles change ### šŸ¢ Organizational Hierarchy diff --git a/appinfo/info.xml b/appinfo/info.xml index a6933c14..92c5d11e 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Submit a [bug report](https://github.com/OpenCatalogi/.github/issues/new/choose) Submit a [feature request](https://github.com/OpenCatalogi/.github/issues/new/choose). ]]> - 0.1.36 + 0.1.57 agpl organization Conduction diff --git a/appinfo/routes.php b/appinfo/routes.php index c88b7450..938793ee 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -36,6 +36,9 @@ // Organization synchronization routes ['name' => 'settings#getSyncStatus', 'url' => '/api/settings/sync-status', 'verb' => 'GET'], ['name' => 'settings#performSync', 'url' => '/api/settings/sync', 'verb' => 'POST'], + + // Heartbeat route for keeping connections alive during long operations + ['name' => 'settings#heartbeat', 'url' => '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/api/heartbeat', 'verb' => 'POST'], // Version and import management routes ['name' => 'settings#getVersionInfo', 'url' => '/api/settings/version', 'verb' => 'GET'], @@ -59,6 +62,9 @@ // Health check endpoint ['name' => 'settings#health_check', 'url' => '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/api/health', 'verb' => 'GET'], + // Configuration cache management + ['name' => 'settings#clear_cache', 'url' => '/api/settings/clear-cache', 'verb' => 'POST'], + // Force re-initialization endpoint ['name' => 'settings#force_reinit', 'url' => '/api/settings/force-reinit', 'verb' => 'POST'], @@ -116,5 +122,33 @@ // User Groups focused endpoints ['name' => 'settings#getUserGroupsConfig', 'url' => '/api/user-groups/config', 'verb' => 'GET'], ['name' => 'settings#updateUserGroupsConfig', 'url' => '/api/user-groups/config', 'verb' => 'POST'], + + // General Settings focused endpoints + ['name' => 'settings#getGeneralConfig', 'url' => '/api/settings/general/config', 'verb' => 'GET'], + ['name' => 'settings#updateGeneralConfig', 'url' => '/api/settings/general/config', 'verb' => 'POST'], + + // Organization Synchronization focused endpoints + ['name' => 'settings#getSyncConfig', 'url' => '/api/settings/sync/config', 'verb' => 'GET'], + ['name' => 'settings#updateSyncConfig', 'url' => '/api/settings/sync/config', 'verb' => 'POST'], + + // ======================================================================== + // VIEW API ENDPOINTS - ArchiMate Views with Enrichment Support + // ======================================================================== + + // View API endpoints for querying and enriching ArchiMate views + ['name' => 'view#getAllViews', 'url' => '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/api/views', 'verb' => 'GET'], + ['name' => 'view#getApiDocumentation', 'url' => '/api/views/docs', 'verb' => 'GET'], + ['name' => 'view#getView', 'url' => '/api/views/{viewId}', 'verb' => 'GET'], + + // ======================================================================== + // AANGEBODEN GEBRUIK API ENDPOINTS - Custom Objects API for Gebruiks + // ======================================================================== + + // AangebodenGebruik API endpoints for filtering gebruiks by organization involvement + ['name' => 'aangebodenGebruik#getGebruiksWhereAfnemer', 'url' => '/api/aangeboden-gebruik/afnemer', 'verb' => 'GET'], + ['name' => 'aangebodenGebruik#getGebruiksWhereDeelnemers', 'url' => '/api/aangeboden-gebruik/deelnemers', 'verb' => 'GET'], + ['name' => 'aangebodenGebruik#setGebruikSelfToActiveOrg', 'url' => '/api/aangeboden-gebruik/{gebruikId}/set-self', 'verb' => 'PUT'], + ['name' => 'aangebodenGebruik#getApiDocumentation', 'url' => '/api/aangeboden-gebruik/docs', 'verb' => 'GET'], + ], ]; diff --git a/check_basic.php b/check_basic.php new file mode 100644 index 00000000..820bde86 --- /dev/null +++ b/check_basic.php @@ -0,0 +1,29 @@ +getDatabaseConnection(); + +try { + // Try to just select from the table without COUNT + $query = $db->getQueryBuilder(); + $query->select('*') + ->from('openregister_objects') + ->setMaxResults(1); + $result = $query->execute(); + $row = $result->fetch(); + + if ($row) { + echo "āœ“ openregister_objects table exists!" . PHP_EOL; + echo "Columns: " . implode(', ', array_keys($row)) . PHP_EOL; + echo "Sample row:" . PHP_EOL; + foreach ($row as $key => $value) { + $displayValue = is_string($value) ? substr($value, 0, 100) : $value; + echo " $key: " . $displayValue . PHP_EOL; + } + } else { + echo "Table exists but is empty" . PHP_EOL; + } +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . PHP_EOL; +} + diff --git a/check_db.php b/check_db.php new file mode 100644 index 00000000..b9c94240 --- /dev/null +++ b/check_db.php @@ -0,0 +1,38 @@ +getDatabaseConnection(); +$query = $db->getQueryBuilder(); +$query->select('id', 'slug', 'object') + ->from('softwarecatalog_objects') + ->where($query->expr()->like('id', $query->createNamedParameter('%d4572e2e%'))) + ->setMaxResults(1); +$result = $query->execute(); +$row = $result->fetch(); + +if ($row) { + echo 'ID: ' . $row['id'] . PHP_EOL; + echo 'Slug: ' . ($row['slug'] ?: '(empty)') . PHP_EOL; + echo 'Object (first 500 chars): ' . substr($row['object'], 0, 500) . '...' . PHP_EOL; + echo PHP_EOL . 'Full object structure:' . PHP_EOL; + $obj = json_decode($row['object'], true); + if ($obj) { + // Show @self structure + if (isset($obj['@self'])) { + echo '@self: ' . json_encode($obj['@self'], JSON_PRETTY_PRINT) . PHP_EOL; + } + // Show flattened properties + $flattenedProps = []; + foreach ($obj as $key => $value) { + if (!in_array($key, ['@self', 'xml', 'id', 'identifier', 'section', 'model_identifier', 'extracted_at'])) { + $flattenedProps[$key] = $value; + } + } + if (!empty($flattenedProps)) { + echo 'Flattened properties: ' . json_encode($flattenedProps, JSON_PRETTY_PRINT) . PHP_EOL; + } + } +} else { + echo 'No matching objects found' . PHP_EOL; +} + diff --git a/check_openregister.php b/check_openregister.php new file mode 100644 index 00000000..70a65895 --- /dev/null +++ b/check_openregister.php @@ -0,0 +1,47 @@ +getDatabaseConnection(); + +// Try different possible table names +$possibleTables = [ + 'oc_openregister_objects', + 'oc_openregister_object', + 'openregister_objects', + 'objects', + 'oc_softwarecatalog_objects' +]; + +foreach ($possibleTables as $tableName) { + try { + $query = $db->getQueryBuilder(); + $query->select('COUNT(*)') + ->from($tableName); + $result = $query->execute(); + $count = $result->fetchOne(); + echo "Table '$tableName' exists with $count rows" . PHP_EOL; + + // If we found a table with data, let's check for our specific object + if ($count > 0) { + $query2 = $db->getQueryBuilder(); + $query2->select('id', 'slug', 'object') + ->from($tableName) + ->where($query2->expr()->like('id', $query2->createNamedParameter('%d4572e2e%'))) + ->setMaxResults(1); + $result2 = $query2->execute(); + $row = $result2->fetch(); + + if ($row) { + echo "Found matching object in '$tableName':" . PHP_EOL; + echo "ID: " . $row['id'] . PHP_EOL; + echo "Slug: " . ($row['slug'] ?: '(empty)') . PHP_EOL; + echo "Object preview: " . substr($row['object'], 0, 200) . '...' . PHP_EOL; + break; + } + } + } catch (Exception $e) { + // Table doesn't exist, continue + continue; + } +} + diff --git a/check_tables.php b/check_tables.php new file mode 100644 index 00000000..f6f4de86 --- /dev/null +++ b/check_tables.php @@ -0,0 +1,47 @@ +getDatabaseConnection(); + +// Get all tables that contain 'softwarecatalog' or 'catalog' +$query = $db->getQueryBuilder(); +$query->select('TABLE_NAME') + ->from('information_schema.TABLES') + ->where($query->expr()->eq('TABLE_SCHEMA', $query->createNamedParameter('nextcloud'))) + ->andWhere($query->expr()->orX( + $query->expr()->like('TABLE_NAME', $query->createNamedParameter('%softwarecatalog%')), + $query->expr()->like('TABLE_NAME', $query->createNamedParameter('%catalog%')) + )); + +try { + $result = $query->execute(); + $tables = []; + while ($row = $result->fetch()) { + $tables[] = $row['TABLE_NAME']; + } + + if (empty($tables)) { + echo "No tables found containing 'softwarecatalog' or 'catalog'" . PHP_EOL; + + // Let's check for any tables that might be related + $query2 = $db->getQueryBuilder(); + $query2->select('TABLE_NAME') + ->from('information_schema.TABLES') + ->where($query2->expr()->eq('TABLE_SCHEMA', $query2->createNamedParameter('nextcloud'))) + ->andWhere($query2->expr()->like('TABLE_NAME', $query2->createNamedParameter('%object%'))); + + $result2 = $query2->execute(); + echo "Tables containing 'object':" . PHP_EOL; + while ($row = $result2->fetch()) { + echo "- " . $row['TABLE_NAME'] . PHP_EOL; + } + } else { + echo "Found tables:" . PHP_EOL; + foreach ($tables as $table) { + echo "- " . $table . PHP_EOL; + } + } +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . PHP_EOL; +} + diff --git a/debug_export_issues.php b/debug_export_issues.php new file mode 100644 index 00000000..5ead81ec --- /dev/null +++ b/debug_export_issues.php @@ -0,0 +1,208 @@ +getDatabaseConnection(); + + // First check what columns exist + $stmt = $db->prepare('SHOW COLUMNS FROM oc_openregister_objects'); + $stmt->execute(); + $columns = $stmt->fetchAll(); + echo "Database columns available:\n"; + foreach (array_slice($columns, 0, 10) as $column) { + echo " - {$column['Field']} ({$column['Type']})\n"; + } + echo "\n"; + + // Check total objects count + $stmt = $db->prepare('SELECT COUNT(*) as count FROM oc_openregister_objects'); + $stmt->execute(); + $totalCount = $stmt->fetch()['count']; + echo "Total objects in database: $totalCount\n"; + + // Check if register column exists and what values it has + try { + $stmt = $db->prepare('SELECT DISTINCT register, COUNT(*) as count FROM oc_openregister_objects GROUP BY register ORDER BY count DESC LIMIT 10'); + $stmt->execute(); + $registers = $stmt->fetchAll(); + echo "Register distribution:\n"; + foreach ($registers as $register) { + echo " - Register {$register['register']}: {$register['count']} objects\n"; + } + + // Focus on register 20 (AMEF) + $stmt = $db->prepare('SELECT COUNT(*) as count FROM oc_openregister_objects WHERE register = ?'); + $stmt->execute([20]); + $amefCount = $stmt->fetch()['count']; + } catch (Exception $e) { + echo "Could not check by register column: {$e->getMessage()}\n"; + $amefCount = $totalCount; // Use total as fallback + } + + if ($amefCount == 0) { + echo "āŒ No objects found in AMEF register! Import may have failed.\n"; + exit(1); + } + + echo "āœ… Found $amefCount objects to potentially export\n\n"; + + // Step 2: Check export service availability + echo "2ļøāƒ£ CHECKING EXPORT SERVICE\n"; + echo "---------------------------\n"; + + $container = \OC::$server->get(\Psr\Container\ContainerInterface::class); + + try { + $archiMateService = $container->get('OCA\\SoftwareCatalog\\Service\\ArchiMateService'); + echo "āœ… ArchiMateService available\n"; + } catch (Exception $e) { + echo "āŒ ArchiMateService not available: {$e->getMessage()}\n"; + exit(1); + } + + // Step 3: Test export method directly (bypass API) + echo "\n3ļøāƒ£ TESTING EXPORT METHOD DIRECTLY\n"; + echo "-----------------------------------\n"; + + try { + $settingsService = $container->get('OCA\\SoftwareCatalog\\Service\\SettingsService'); + + // Test basic export parameters + $exportOptions = [ + 'format' => 'archimate', + 'includeRelationships' => true, + 'includeViews' => false, // Start simple + 'organizationSpecific' => false + ]; + + echo "Attempting direct export with options:\n"; + print_r($exportOptions); + echo "\n"; + + // Check if export method exists + $reflection = new ReflectionClass($archiMateService); + if ($reflection->hasMethod('exportArchiMateXml')) { + echo "āœ… exportArchiMateXml method exists\n"; + + // Try to call it + $method = $reflection->getMethod('exportArchiMateXml'); + if ($method->isPublic() || $method->isProtected()) { + $method->setAccessible(true); + + echo "Calling export method...\n"; + $startTime = microtime(true); + + $result = $method->invoke($archiMateService, $exportOptions); + + $duration = microtime(true) - $startTime; + echo "Export completed in " . round($duration, 3) . " seconds\n"; + + if (is_array($result) && isset($result['success'])) { + if ($result['success']) { + echo "āœ… Export successful!\n"; + if (isset($result['xml'])) { + $xmlLength = strlen($result['xml']); + echo "Generated XML length: $xmlLength bytes\n"; + + // Validate XML + libxml_use_internal_errors(true); + $dom = new DOMDocument(); + if ($dom->loadXML($result['xml'])) { + echo "āœ… Generated XML is valid\n"; + + // Save sample for inspection + file_put_contents('/tmp/debug_export_sample.xml', $result['xml']); + echo "Sample saved to /tmp/debug_export_sample.xml\n"; + } else { + echo "āŒ Generated XML is invalid:\n"; + $errors = libxml_get_errors(); + foreach ($errors as $error) { + echo " - Line {$error->line}: {$error->message}"; + } + } + } + } else { + echo "āŒ Export failed: {$result['message']}\n"; + if (isset($result['error'])) { + echo "Error details: {$result['error']}\n"; + } + } + } else { + echo "āŒ Unexpected export result format\n"; + print_r($result); + } + } else { + echo "āŒ Export method is not accessible\n"; + } + } else { + echo "āŒ exportArchiMateXml method does not exist\n"; + + // List available methods + echo "Available methods:\n"; + $methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC); + foreach ($methods as $method) { + if (strpos(strtolower($method->getName()), 'export') !== false) { + echo " - {$method->getName()}\n"; + } + } + } + + } catch (Exception $e) { + echo "āŒ Direct export test failed: {$e->getMessage()}\n"; + echo "Stack trace:\n{$e->getTraceAsString()}\n"; + } + + // Step 4: Check SettingsController export method + echo "\n4ļøāƒ£ CHECKING SETTINGS CONTROLLER\n"; + echo "--------------------------------\n"; + + try { + $settingsController = $container->get('OCA\\SoftwareCatalog\\Controller\\SettingsController'); + echo "āœ… SettingsController available\n"; + + $reflection = new ReflectionClass($settingsController); + if ($reflection->hasMethod('exportArchiMate')) { + echo "āœ… exportArchiMate method exists in controller\n"; + } else { + echo "āŒ exportArchiMate method missing in controller\n"; + } + + } catch (Exception $e) { + echo "āŒ SettingsController issue: {$e->getMessage()}\n"; + } + + // Step 5: Summary and recommendations + echo "\n5ļøāƒ£ SUMMARY & RECOMMENDATIONS\n"; + echo "=============================\n"; + + if ($amefCount > 0) { + echo "āœ… Data is available for export\n"; + echo "šŸ“‹ NEXT STEPS:\n"; + echo " 1. Fix the export method implementation\n"; + echo " 2. Ensure XML generation is working correctly\n"; + echo " 3. Test with simple export options first\n"; + echo " 4. Gradually add complexity (relationships, views)\n"; + } else { + echo "āŒ No data available - import issues need to be resolved first\n"; + } + +} catch (Exception $e) { + echo "\nšŸ’„ DEBUG SCRIPT FAILED: {$e->getMessage()}\n"; + echo "Stack trace:\n{$e->getTraceAsString()}\n"; + exit(1); +} +?> diff --git a/debug_slug_logic.php b/debug_slug_logic.php new file mode 100644 index 00000000..cb425b4b --- /dev/null +++ b/debug_slug_logic.php @@ -0,0 +1,69 @@ + 'id-d4572e2e-aa3e-4cbe-94aa-16f0e2ae9620', + 'section' => 'elements', + 'model_identifier' => 'some-model-id', + 'extracted_at' => time(), + 'xml' => [], // XML data + 'GEMMA type' => 'Buitengemeentelijke dienst', + 'Object ID' => 'd4572e2e-aa3e-4cbe-94aa-16f0e2ae9620', + '_slug' => 'd4572e2e-aa3e-4cbe-94aa-16f0e2ae9620' // This should be set by our property processing +]; + +echo "1. Extracted object structure:" . PHP_EOL; +foreach ($extractedObject as $key => $value) { + if (is_array($value)) { + echo " $key: [array]" . PHP_EOL; + } else { + echo " $key: $value" . PHP_EOL; + } +} + +echo PHP_EOL . "2. Creating @self structure..." . PHP_EOL; + +// This simulates our createSectionObject method +$identifier = $extractedObject['identifier']; +$registerId = 15; +$schemaId = 66; + +$object = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId, + 'id' => $identifier, + 'owner' => 'admin', + 'organisation' => 'default', + 'created' => date('Y-m-d H:i:s'), + 'updated' => date('Y-m-d H:i:s') + ] +]; + +// Check if there's a temporary slug to move to @self structure +if (isset($extractedObject['_slug'])) { + $object['@self']['slug'] = $extractedObject['_slug']; + unset($extractedObject['_slug']); // Remove the temporary field + echo "āœ“ Found _slug, moved to @self.slug: " . $object['@self']['slug'] . PHP_EOL; +} else { + echo "āŒ No _slug found in extracted object" . PHP_EOL; +} + +// Merge the rest of the data +$finalObject = array_merge($object, $extractedObject); + +echo PHP_EOL . "3. Final object @self structure:" . PHP_EOL; +foreach ($finalObject['@self'] as $key => $value) { + echo " @self.$key: $value" . PHP_EOL; +} + +echo PHP_EOL . "4. Expected database mapping:" . PHP_EOL; +echo " uuid column: " . $finalObject['@self']['id'] . PHP_EOL; +echo " slug column: " . ($finalObject['@self']['slug'] ?? '(NOT SET)') . PHP_EOL; +echo " register column: " . $finalObject['@self']['register'] . PHP_EOL; +echo " schema column: " . $finalObject['@self']['schema'] . PHP_EOL; + diff --git a/debug_xml_output.php b/debug_xml_output.php new file mode 100644 index 00000000..e0f4fb93 --- /dev/null +++ b/debug_xml_output.php @@ -0,0 +1,47 @@ +get(\Psr\Container\ContainerInterface::class); + $archiMateService = $container->get('OCA\\SoftwareCatalog\\Service\\ArchiMateService'); + + echo "Starting export with QA disabled...\n"; + + // We need to temporarily disable QA to see the raw XML + // Let's try exporting and catch the raw XML before QA validation + + $result = $archiMateService->exportToArchiMate(null); + + if (isset($result['success']) && !$result['success']) { + echo "Export failed: " . ($result['error'] ?? 'Unknown error') . "\n"; + + // The error happens during QA validation, so let's try to get more details + if (isset($result['error']) && str_contains($result['error'], 'QA validation failed')) { + echo "\n=== XML GENERATION APPEARS TO WORK, BUT QA VALIDATION FAILS ===\n"; + echo "This suggests the XML is being generated but is malformed.\n"; + echo "Need to examine the XML string before QA validation.\n"; + } + } else { + echo "Export succeeded!\n"; + print_r($result); + } + +} catch (Exception $e) { + echo "\nšŸ’„ Debug failed: " . $e->getMessage() . "\n"; + echo "Trace: " . $e->getTraceAsString() . "\n"; +} + +echo "\nšŸ Debug completed\n"; +?> + diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e3fe8aef..ee66d8e8 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -299,7 +299,7 @@ try { $service->processContactgegevens($object); } catch (ProcessingException $e) { // Handle processing error - error_log('Processing failed: ' . $e->getMessage()); + $logger->error('Processing failed: ' . $e->getMessage()); } ``` @@ -311,7 +311,7 @@ try { $schemaId = $settingsService->getSchemaIdForObjectType('contactgegevens'); } catch (ConfigurationException $e) { // Handle configuration error - error_log('Configuration error: ' . $e->getMessage()); + $logger->error('Configuration error: ' . $e->getMessage()); } ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ff71911..1c6f2391 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -207,10 +207,10 @@ Handles application initialization: ā”œā”€ā”€ Get organization group ID └── Add user to organization group ↓ -5. Special group assignment (gemeente → ambtenaar) - ā”œā”€ā”€ Check organization type - ā”œā”€ā”€ Create ambtenaar group if needed - └── Add user to ambtenaar group +5. Group management (manual assignment only) + ā”œā”€ā”€ Create required groups if needed + ā”œā”€ā”€ 'ambtenaar' group available for manual assignment + └── No automatic assignment based on organization type ``` ## Synchronization Architecture (NEW) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e7fc86e1..a61646de 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -205,7 +205,7 @@ Groups are automatically created for: 1. **Role-Based Groups**: 'beheerder', 'inkoper' 2. **Organization Groups**: Based on organization name -3. **Special Groups**: 'ambtenaar' for gemeente organizations +3. **Special Groups**: 'ambtenaar' available for manual assignment (no automatic assignment) ### Group Naming Rules diff --git a/docs/GROUP_MANAGEMENT.md b/docs/GROUP_MANAGEMENT.md index 057d3c46..0780663e 100644 --- a/docs/GROUP_MANAGEMENT.md +++ b/docs/GROUP_MANAGEMENT.md @@ -8,7 +8,7 @@ The group management system handles three main types of group assignments: 1. **Role-Based Groups** - Groups based on user roles ('beheerder', 'inkoper') 2. **Organization Groups** - Groups specific to each organization -3. **Special Groups** - Special groups like 'ambtenaar' for gemeente organizations +3. **Special Groups** - Special groups like 'ambtenaar' available for manual assignment ## Role-Based Groups @@ -71,8 +71,8 @@ Users are automatically added to their organization's group based on the 'organi Organizations with 'type: "gemeente"' trigger special handling: -- Users automatically get added to the 'ambtenaar' group -- This is in addition to all other group assignments +- The 'ambtenaar' group is available for manual assignment +- Users can be manually assigned to or removed from the 'ambtenaar' group - The 'ambtenaar' group is created automatically if it doesn't exist ## Automatic Beheerder Assignment @@ -140,7 +140,7 @@ When a contactgegevens object is created or updated: 1. **Username Generation**: Create/validate username from name fields 2. **Role-Based Groups**: Assign user to groups based on 'roles' property 3. **Organization Groups**: Add user to organization-specific group -4. **Special Groups**: Add to 'ambtenaar' if organization is gemeente +4. **Special Groups**: 'ambtenaar' group available for manual assignment (no automatic assignment) 5. **Beheerder Check**: Ensure organization has at least one beheerder 6. **Manager Assignment**: Set up manager relationships diff --git a/docs/README.md b/docs/README.md index e901a0dd..a9014ada 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ Welcome to the Software Catalog app documentation. This app provides comprehensi ### šŸ‘„ Advanced Group Management - **Role-Based Groups**: Automatic assignment to groups based on user roles (beheerder, inkoper) - **Organization Groups**: Each organization gets its own group with automatic member assignment -- **Special Groups**: Municipality (gemeente) users automatically get 'ambtenaar' group +- **Special Groups**: The 'ambtenaar' group is available for manual assignment (no longer automatically assigned) - **Dynamic Updates**: Group memberships automatically updated when roles change ### šŸ¢ Organizational Hierarchy @@ -115,7 +115,7 @@ Gebruiker Schema: 28 ā”œā”€ā”€ Creates user: jane.doe ā”œā”€ā”€ Assigns to 'beheerder' group ā”œā”€ā”€ Assigns to 'gemeente_amsterdam' group - ā”œā”€ā”€ Assigns to 'ambtenaar' group (gemeente type) + ā”œā”€ā”€ Note: 'ambtenaar' group available for manual assignment └── Sets as organization manager (first beheerder) 3. Next User in Same Organization @@ -223,7 +223,7 @@ Each documentation file provides detailed information for specific audiences: - āœ… Role-based group assignment (beheerder, inkoper) - āœ… Organization-specific groups - āœ… Manager hierarchy management -- āœ… Special gemeente handling (ambtenaar group) +- āœ… 'ambtenaar' group available for manual assignment - āœ… Multi-register support (AMEF, Voorzieningen, Generic) - āœ… Type-safe event processing - āœ… Comprehensive error handling diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index cd47794e..266bc4df 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -29,7 +29,7 @@ You may be automatically assigned to several types of groups: - Example: 'gemeente_amsterdam' for Gemeente Amsterdam employees #### Special Groups -- **ambtenaar** - If you work for a gemeente (municipality) +- **ambtenaar** - Available for manual assignment (civil servant role) ### Your Manager @@ -84,7 +84,7 @@ To view your groups and manager: The system creates these groups automatically: - 'beheerder' - Administrator role - 'inkoper' - Purchaser role -- 'ambtenaar' - Municipality employee role +- 'ambtenaar' - Civil servant role (manual assignment) - Organization-specific groups (e.g., 'gemeente_amsterdam') **Manual Group Operations:** diff --git a/docs/View_API.md b/docs/View_API.md new file mode 100644 index 00000000..584301ce --- /dev/null +++ b/docs/View_API.md @@ -0,0 +1,330 @@ +# View API Documentation + +## Overview + +The SoftwareCatalog View API provides endpoints for querying and enriching ArchiMate views with additional data such as products, usage information (gebruik), and participation data (deelnames gebruik). + +**Base URL**: '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/api/views' +**API Version**: 1.0.0 + +## Authentication + +All View API endpoints are currently configured as: +- `@NoAdminRequired` - No admin privileges required +- `@NoCSRFRequired` - No CSRF token required +- `@PublicPage` - Publicly accessible + +> Note: In production, you may want to adjust these security settings based on your requirements. + +## Endpoints + +### 1. Get All Views + +**Endpoint**: `GET /api/views` + +Returns all available ArchiMate views with optional enrichment data. + +#### Query Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `include_products` | boolean | No | false | Include product data in view nodes | +| `include_gebruik` | boolean | No | false | Include usage data in view nodes | +| `include_deelnames_gebruik` | boolean | No | false | Include participation usage data in view nodes | + +#### Boolean Parameter Formats + +The API accepts various formats for boolean parameters: +- **True values**: `true`, `1`, `yes`, `on`, `True`, `TRUE` +- **False values**: `false`, `0`, `no`, `off`, `False`, `FALSE` + +#### Example Requests + +```bash +# Get all views without enrichment +GET /api/views + +# Get all views with product data +GET /api/views?include_products=true + +# Get all views with multiple enrichments +GET /api/views?include_products=1&include_gebruik=yes&include_deelnames_gebruik=true +``` + +#### Response Format + +```json +{ + "success": true, + "views": [ + { + "id": "view-lv01", + "name": "LV01 BGT basisregistratie en SVB view", + "documentation": "Mocked from GEMMA LV01.", + "viewNodes": [ + { + "modelNodeId": "bs-assembleren", + "viewNodeId": "vn-assembleren-bgt", + "x": 7, + "y": 7, + "width": 341, + "height": 36, + "parent": null, + "name": "Assembleren BGT aanleveringen van bronhouders", + "type": "businessservice", + "color": "rgb(255, 255, 204)", + "borderColor": "rgb(0, 0, 0)", + "font": { + "name": "Segoe UI, Arial", + "size": 12, + "style": "normal", + "color": "rgb(0, 0, 0)" + }, + "description": null, + "elementRef": "bs-assembleren", + "products": [], // Only present if include_products=true + "gebruik": [], // Only present if include_gebruik=true + "deelnamesGebruik": [] // Only present if include_deelnames_gebruik=true + } + ], + "viewRelationships": [ + { + "modelRelationshipId": "rel-1", + "sourceId": "vn-aanleveringen-bgt", + "targetId": "vn-stuf-imgeo", + "viewRelationshipId": "rel-1", + "type": "realization", + "bendpoints": [ + { + "x": 92.5, + "y": 282.5 + } + ], + "label": {} + } + ] + } + ], + "count": 1, + "enrichments_applied": ["products", "gebruik"] +} +``` + +### 2. Get Specific View + +**Endpoint**: `GET /api/views/{viewId}` + +Returns a specific ArchiMate view by its identifier with optional enrichment data. + +#### URL Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `viewId` | string | Yes | The unique identifier of the view to retrieve | + +#### Query Parameters + +Same as "Get All Views" endpoint. + +#### Example Requests + +```bash +# Get specific view without enrichment +GET /api/views/view-lv01 + +# Get specific view with all enrichments +GET /api/views/view-lv01?include_products=true&include_gebruik=true&include_deelnames_gebruik=true +``` + +#### Response Format + +```json +{ + "success": true, + "view": { + "id": "view-lv01", + "name": "LV01 BGT basisregistratie en SVB view", + "documentation": "Mocked from GEMMA LV01.", + "viewNodes": [], + "viewRelationships": [] + }, + "enrichments_applied": [] +} +``` + +#### Error Response (View Not Found) + +```json +{ + "success": false, + "error": "View with ID 'non-existent-view' not found", + "view": null +} +``` + +### 3. Get API Documentation + +**Endpoint**: `GET /api/views/docs` + +Returns comprehensive API documentation in JSON format. + +#### Example Request + +```bash +GET /api/views/docs +``` + +#### Response Format + +Returns a detailed JSON object containing API version, endpoints, parameters, and examples. + +## Data Structures + +### ViewNode Structure + +Each view node contains the following properties: + +| Property | Type | Description | +|----------|------|-------------| +| `modelNodeId` | string | Reference to the ArchiMate element | +| `viewNodeId` | string | Unique identifier within the view | +| `x`, `y` | integer | Position coordinates | +| `width`, `height` | integer | Dimensions | +| `parent` | string\|null | Parent node for nested structures | +| `name` | string | Display name | +| `type` | string | Element type (e.g., "businessservice") | +| `color` | string | Background color in RGB format | +| `borderColor` | string | Border color in RGB format | +| `font` | object | Font styling information | +| `description` | string\|null | Element description | +| `elementRef` | string | Reference to the source element | + +### ViewRelationship Structure + +Each view relationship contains: + +| Property | Type | Description | +|----------|------|-------------| +| `modelRelationshipId` | string | Reference to the ArchiMate relationship | +| `sourceId` | string | Source node viewNodeId | +| `targetId` | string | Target node viewNodeId | +| `viewRelationshipId` | string | Unique identifier within the view | +| `type` | string | Relationship type (e.g., "realization") | +| `bendpoints` | array | Array of bend point coordinates | +| `label` | object | Label information and styling | + +## Enrichment Options + +### Products (`include_products=true`) + +When enabled, adds a `products` array to each viewNode containing product information related to the referenced element. + +**Implementation Status**: Placeholder - requires implementation of product data retrieval logic. + +### Gebruik (`include_gebruik=true`) + +When enabled, adds a `gebruik` array to each viewNode containing usage statistics and data. + +**Implementation Status**: Placeholder - requires implementation of usage data retrieval logic. + +### Deelnames Gebruik (`include_deelnames_gebruik=true`) + +When enabled, adds a `deelnamesGebruik` array to each viewNode containing participation-based usage data. + +**Implementation Status**: Placeholder - requires implementation of participation data retrieval logic. + +## Error Handling + +### HTTP Status Codes + +- `200 OK` - Request successful +- `400 Bad Request` - Invalid parameters (e.g., empty viewId) +- `404 Not Found` - View not found +- `500 Internal Server Error` - Server error + +### Error Response Format + +```json +{ + "success": false, + "error": "Error description", + "view": null // or "views": [] for getAllViews +} +``` + +## Implementation Notes + +### Performance Considerations + +- The API uses the ViewService for business logic separation +- Enrichment data is loaded upfront when any enrichment option is enabled +- View nodes are processed in batches for optimal performance +- Database queries are optimized using OpenRegister's ObjectService + +### Future Enhancements + +The API is designed to support future enhancements: + +1. **Authentication & Authorization**: Add proper security controls +2. **Pagination**: Support for large result sets +3. **Filtering**: Add query parameters for filtering views +4. **Caching**: Implement response caching for improved performance +5. **Real Enrichment Logic**: Replace placeholder enrichment methods with actual implementations + +### Dependencies + +- **OpenRegister**: Required for data storage and retrieval +- **AMEF Configuration**: Must be properly configured for view schema access +- **SettingsService**: Used for configuration management + +## Testing + +### Using curl + +```bash +# Test basic functionality +curl -X GET "http://localhost/index.php/apps/softwarecatalog/api/views" \\ + -H "Content-Type: application/json" + +# Test with enrichment +curl -X GET "http://localhost/index.php/apps/softwarecatalog/api/views?include_products=true" \\ + -H "Content-Type: application/json" + +# Test specific view +curl -X GET "http://localhost/index.php/apps/softwarecatalog/api/views/view-lv01" \\ + -H "Content-Type: application/json" +``` + +### Expected Data Flow + +1. **Client Request** → ViewController +2. **ViewController** → ViewService (business logic) +3. **ViewService** → OpenRegister ObjectService (data retrieval) +4. **ViewService** → Enrichment methods (if enabled) +5. **ViewService** → Response formatting +6. **ViewController** → JSON response to client + +## Integration Examples + +### JavaScript/Frontend Integration + +```javascript +// Get all views with products +const response = await fetch('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/api/views?include_products=true'); +const data = await response.json(); + +if (data.success) { + console.log('Found views:', data.count); + data.views.forEach(view => { + console.log('View:', view.name); + view.viewNodes.forEach(node => { + if (node.products && node.products.length > 0) { + console.log(' Node products:', node.products); + } + }); + }); +} +``` + +This completes the View API implementation with full documentation for querying ArchiMate views with enrichment capabilities. + diff --git a/enhance_archimate_service.php b/enhance_archimate_service.php new file mode 100644 index 00000000..14848e82 --- /dev/null +++ b/enhance_archimate_service.php @@ -0,0 +1,295 @@ +logger->info(\'Saving objects to database using parallel batch processing\', ['; +$replacement1 = ' // ENHANCEMENT: Process GEMMA Referentiecomponent-Standaard relationships before saving + $objects = $this->processGemmaReferenceComponentStandards($objects); + + $this->logger->info(\'Saving objects to database using parallel batch processing\', ['; + +if (strpos($content, 'processGemmaReferenceComponentStandards') === false) { + $content = str_replace($searchPattern1, $replacement1, $content); + echo "āœ… Added processGemmaReferenceComponentStandards call to saveObjectsToDatabase\n"; +} else { + echo "ā„¹ļø processGemmaReferenceComponentStandards call already exists\n"; +} + +// 2. Add the new methods before the closing brace +$newMethods = ' + /** + * Process GEMMA Referentiecomponent-Standaard relationships with Verbindingsrol support + * + * This method analyzes all objects to find Referentiecomponenten and Standaarden, + * then uses relationships to link them together based on Verbindingsrol property. + * Each Referentiecomponent gets two properties: + * - \'aanbevolenStandaarden\' array for standards with Verbindingsrol = "Aanbevolen" + * - \'verplichteStandaarden\' array for standards with Verbindingsrol = "Verplicht" + * + * @param array $objects All objects from the import + * @return array Objects with enhanced Referentiecomponent data + */ + private function processGemmaReferenceComponentStandards(array $objects): array + { + $this->logger->info(\'Processing GEMMA Referentiecomponent-Standaard relationships with Verbindingsrol support\'); + + // STEP 1: Filter objects by GEMMA type using flattened camelCase properties + $referentieComponenten = []; + $standaarden = []; + $relationships = []; + + foreach ($objects as $index => $object) { + // Check if this is an element with GEMMA type property + if (isset($object[\'section\']) && $object[\'section\'] === \'elements\' && isset($object[\'gemmaType\'])) { + if ($object[\'gemmaType\'] === \'Referentiecomponent\') { + $referentieComponenten[$object[\'identifier\']] = $index; + $this->logger->debug(\'Found Referentiecomponent\', [ + \'identifier\' => $object[\'identifier\'], + \'name\' => $object[\'name\'] ?? \'Unknown\' + ]); + } elseif ($object[\'gemmaType\'] === \'Standaard\') { + $standaarden[$object[\'identifier\']] = $index; + $this->logger->debug(\'Found Standaard\', [ + \'identifier\' => $object[\'identifier\'], + \'name\' => $object[\'name\'] ?? \'Unknown\' + ]); + } + } + + // Collect relationships for linking + if (isset($object[\'section\']) && $object[\'section\'] === \'relationships\') { + $relationships[] = $object; + } + } + + $this->logger->info(\'GEMMA objects found\', [ + \'referentiecomponenten_count\' => count($referentieComponenten), + \'standaarden_count\' => count($standaarden), + \'relationships_count\' => count($relationships) + ]); + + // STEP 2: Process relationships to find connections with Verbindingsrol + $referentieComponentStandaardMap = []; + + foreach ($relationships as $relationship) { + // Get source and target from relationship XML or flattened properties + $source = $this->extractRelationshipEndpoint($relationship, \'source\'); + $target = $this->extractRelationshipEndpoint($relationship, \'target\'); + + if (!$source || !$target) { + continue; + } + + // Get Verbindingsrol from flattened properties (camelCase: verbindingsrol) + $verbindingsrol = $relationship[\'verbindingsrol\'] ?? null; + + // Skip if no Verbindingsrol is defined + if (!$verbindingsrol) { + continue; + } + + // Check if one end is a Referentiecomponent and the other is a Standaard + $refCompId = null; + $standaardId = null; + + if (isset($referentieComponenten[$source]) && isset($standaarden[$target])) { + // Referentiecomponent -> Standaard + $refCompId = $source; + $standaardId = $target; + } elseif (isset($standaarden[$source]) && isset($referentieComponenten[$target])) { + // Standaard -> Referentiecomponent (reverse direction) + $refCompId = $target; + $standaardId = $source; + } + + if ($refCompId && $standaardId) { + // Initialize arrays if not exists + if (!isset($referentieComponentStandaardMap[$refCompId])) { + $referentieComponentStandaardMap[$refCompId] = [ + \'aanbevolen\' => [], + \'verplicht\' => [] + ]; + } + + // Add to appropriate array based on Verbindingsrol + if (strtolower($verbindingsrol) === \'aanbevolen\') { + $referentieComponentStandaardMap[$refCompId][\'aanbevolen\'][] = $standaardId; + } elseif (strtolower($verbindingsrol) === \'verplicht\') { + $referentieComponentStandaardMap[$refCompId][\'verplicht\'][] = $standaardId; + } else { + // Log unknown Verbindingsrol for debugging + $this->logger->warning(\'Unknown Verbindingsrol found\', [ + \'verbindingsrol\' => $verbindingsrol, + \'relationship\' => $relationship[\'identifier\'] ?? \'unknown\', + \'referentiecomponent\' => $refCompId, + \'standaard\' => $standaardId + ]); + continue; + } + + $this->logger->debug(\'Found Referentiecomponent-Standaard link with Verbindingsrol\', [ + \'referentiecomponent\' => $refCompId, + \'standaard\' => $standaardId, + \'verbindingsrol\' => $verbindingsrol, + \'relationship\' => $relationship[\'identifier\'] ?? \'unknown\' + ]); + } + } + + // STEP 3: Add \'aanbevolenStandaarden\' and \'verplichteStandaarden\' properties to Referentiecomponenten + $enhancedCount = 0; + foreach ($referentieComponentStandaardMap as $referentieComponentId => $standaardenMap) { + if (isset($referentieComponenten[$referentieComponentId])) { + $objectIndex = $referentieComponenten[$referentieComponentId]; + + // Remove duplicates and add the properties + $aanbevolenStandaarden = array_unique($standaardenMap[\'aanbevolen\']); + $verplichteStandaarden = array_unique($standaardenMap[\'verplicht\']); + + $objects[$objectIndex][\'aanbevolenStandaarden\'] = $aanbevolenStandaarden; + $objects[$objectIndex][\'verplichteStandaarden\'] = $verplichteStandaarden; + + // Also add combined array for backward compatibility + $allStandaarden = array_unique(array_merge($aanbevolenStandaarden, $verplichteStandaarden)); + $objects[$objectIndex][\'standaarden\'] = $allStandaarden; + + $this->logger->info(\'Enhanced Referentiecomponent with categorized standaarden\', [ + \'referentiecomponent_id\' => $referentieComponentId, + \'referentiecomponent_name\' => $objects[$objectIndex][\'name\'] ?? \'Unknown\', + \'aanbevolen_count\' => count($aanbevolenStandaarden), + \'verplicht_count\' => count($verplichteStandaarden), + \'aanbevolen_ids\' => $aanbevolenStandaarden, + \'verplicht_ids\' => $verplichteStandaarden + ]); + + $enhancedCount++; + } + } + + $this->logger->info(\'GEMMA Referentiecomponent-Standaard processing completed\', [ + \'referentiecomponenten_enhanced\' => $enhancedCount, + \'total_referentiecomponenten\' => count($referentieComponenten), + \'total_relationships_processed\' => count($relationships) + ]); + + return $objects; + } + + /** + * Extract relationship endpoint (source or target) from relationship object + * + * @param array $relationship The relationship object + * @param string $endpoint Either \'source\' or \'target\' + * @return string|null The endpoint identifier or null if not found + */ + private function extractRelationshipEndpoint(array $relationship, string $endpoint): ?string + { + // Try flattened camelCase property first + if (isset($relationship[$endpoint])) { + return $relationship[$endpoint]; + } + + // Try XML structure + if (isset($relationship[\'xml\'][$endpoint])) { + $endpointData = $relationship[\'xml\'][$endpoint]; + + // Handle different XML structures + if (is_string($endpointData)) { + return $endpointData; + } elseif (is_array($endpointData)) { + // Try _attributes.href or _value + if (isset($endpointData[\'_attributes\'][\'href\'])) { + return $endpointData[\'_attributes\'][\'href\']; + } elseif (isset($endpointData[\'_value\'])) { + return $endpointData[\'_value\']; + } + } + } + + // Try direct XML access for ArchiMate format + if (isset($relationship[\'xml\'][\'_attributes\'])) { + $attr = $relationship[\'xml\'][\'_attributes\']; + if ($endpoint === \'source\' && isset($attr[\'source\'])) { + return $attr[\'source\']; + } elseif ($endpoint === \'target\' && isset($attr[\'target\'])) { + return $attr[\'target\']; + } + } + + return null; + } +'; + +// Check if methods already exist +if (strpos($content, 'processGemmaReferenceComponentStandards') === false) { + // Find the last closing brace and add methods before it + $lastBracePos = strrpos($content, '}'); + if ($lastBracePos !== false) { + $content = substr($content, 0, $lastBracePos) . $newMethods . "\n}"; + echo "āœ… Added processGemmaReferenceComponentStandards and extractRelationshipEndpoint methods\n"; + } else { + echo "āŒ Error: Could not find closing brace in ArchiMateService.php\n"; + exit(1); + } +} else { + echo "ā„¹ļø processGemmaReferenceComponentStandards method already exists\n"; +} + +// Write the updated content back to the file +if (file_put_contents($serviceFile, $content) === false) { + echo "āŒ Error: Could not write updated ArchiMateService.php\n"; + exit(1); +} + +echo "āœ… Successfully enhanced ArchiMateService.php\n\n"; + +// Check syntax +echo "Checking PHP syntax...\n"; +$output = []; +$returnCode = 0; +exec("php -l $serviceFile 2>&1", $output, $returnCode); + +if ($returnCode === 0) { + echo "āœ… PHP syntax is valid\n"; +} else { + echo "āŒ PHP syntax errors found:\n"; + foreach ($output as $line) { + echo " $line\n"; + } + exit(1); +} + +echo "\n=== Enhancement Complete ===\n"; +echo "The ArchiMateService now supports:\n"; +echo "- Processing Verbindingsrol property from relationships\n"; +echo "- Creating separate aanbevolenStandaarden and verplichteStandaarden arrays\n"; +echo "- Enhanced logging for debugging\n"; +echo "- Backward compatibility with combined standaarden array\n\n"; + +echo "Next steps:\n"; +echo "1. Test the import with GEMMA_release.xml\n"; +echo "2. Check the logs for enhanced Referentiecomponent processing\n"; +echo "3. Verify the results match the expected table format\n"; + diff --git a/find_objects_simple.php b/find_objects_simple.php new file mode 100644 index 00000000..216b6d2e --- /dev/null +++ b/find_objects_simple.php @@ -0,0 +1,68 @@ +getDatabaseConnection(); + +// Try simpler table names +$possibleTables = [ + 'openregister_objects' +]; + +echo "Checking for OpenRegister tables..." . PHP_EOL; + +foreach ($possibleTables as $tableName) { + try { + $query = $db->getQueryBuilder(); + $query->select('COUNT(*)') + ->from($tableName); + $result = $query->execute(); + $count = $result->fetchOne(); + echo "āœ“ Table '$tableName' exists with $count rows" . PHP_EOL; + + if ($count > 0) { + // Look for our specific object + $query2 = $db->getQueryBuilder(); + $query2->select('*') + ->from($tableName) + ->where($query2->expr()->like('object', $query2->createNamedParameter('%d4572e2e-aa3e-4cbe-94aa-16f0e2ae9620%'))) + ->setMaxResults(1); + $result2 = $query2->execute(); + $row = $result2->fetch(); + + if ($row) { + echo "Found matching object!" . PHP_EOL; + echo "ID: " . $row['id'] . PHP_EOL; + if (isset($row['slug'])) { + echo "Slug: " . ($row['slug'] ?: '(empty)') . PHP_EOL; + } + echo "Object (first 500 chars): " . substr($row['object'], 0, 500) . '...' . PHP_EOL; + + // Parse and show structure + $obj = json_decode($row['object'], true); + if ($obj && isset($obj['@self'])) { + echo PHP_EOL . "@self structure:" . PHP_EOL; + echo json_encode($obj['@self'], JSON_PRETTY_PRINT) . PHP_EOL; + } + + // Show flattened properties + if ($obj) { + $flattened = []; + foreach (['GEMMA type', 'Object ID', 'GEMMA thema'] as $prop) { + if (isset($obj[$prop])) { + $flattened[$prop] = $obj[$prop]; + } + } + if (!empty($flattened)) { + echo PHP_EOL . "Flattened properties:" . PHP_EOL; + echo json_encode($flattened, JSON_PRETTY_PRINT) . PHP_EOL; + } + } + } else { + echo "No matching object found in $tableName" . PHP_EOL; + } + } + } catch (Exception $e) { + echo "Error with table '$tableName': " . $e->getMessage() . PHP_EOL; + } +} + diff --git a/find_objects_table.php b/find_objects_table.php new file mode 100644 index 00000000..fe3dd2cf --- /dev/null +++ b/find_objects_table.php @@ -0,0 +1,68 @@ +getDatabaseConnection(); + +// Try different possible table names for OpenRegister +$possibleTables = [ + 'oc_openregister_objects', + 'oc_openregister_object', + 'oc_or_objects', + 'openregister_objects', + 'objects' +]; + +echo "Checking for OpenRegister tables..." . PHP_EOL; + +foreach ($possibleTables as $tableName) { + try { + $query = $db->getQueryBuilder(); + $query->select('COUNT(*)') + ->from($tableName); + $result = $query->execute(); + $count = $result->fetchOne(); + echo "āœ“ Table '$tableName' exists with $count rows" . PHP_EOL; + + // Show table structure + if ($count > 0) { + $query2 = $db->getQueryBuilder(); + $query2->select('*') + ->from($tableName) + ->setMaxResults(1); + $result2 = $query2->execute(); + $row = $result2->fetch(); + if ($row) { + echo " Columns: " . implode(', ', array_keys($row)) . PHP_EOL; + } + } + } catch (Exception $e) { + // Table doesn't exist, continue silently + continue; + } +} + +// Let's also try to find our specific object by searching in all possible ways +echo PHP_EOL . "Searching for our test object..." . PHP_EOL; + +// Try the most likely table name +try { + $query = $db->getQueryBuilder(); + $query->select('*') + ->from('oc_openregister_objects') + ->where($query->expr()->like('object', $query->createNamedParameter('%d4572e2e-aa3e-4cbe-94aa-16f0e2ae9620%'))) + ->setMaxResults(1); + $result = $query->execute(); + $row = $result->fetch(); + + if ($row) { + echo "Found object in oc_openregister_objects!" . PHP_EOL; + echo "ID: " . $row['id'] . PHP_EOL; + if (isset($row['slug'])) { + echo "Slug: " . ($row['slug'] ?: '(empty)') . PHP_EOL; + } + echo "Object preview: " . substr($row['object'], 0, 300) . '...' . PHP_EOL; + } +} catch (Exception $e) { + echo "Error searching in oc_openregister_objects: " . $e->getMessage() . PHP_EOL; +} + diff --git a/find_test_object.php b/find_test_object.php new file mode 100644 index 00000000..cdc71c31 --- /dev/null +++ b/find_test_object.php @@ -0,0 +1,83 @@ +getDatabaseConnection(); + +try { + // Search for our specific test object + $query = $db->getQueryBuilder(); + $query->select('*') + ->from('openregister_objects') + ->where($query->expr()->like('uuid', $query->createNamedParameter('%d4572e2e-aa3e-4cbe-94aa-16f0e2ae9620%'))) + ->setMaxResults(1); + $result = $query->execute(); + $row = $result->fetch(); + + if ($row) { + echo "šŸŽ‰ Found our test object!" . PHP_EOL; + echo "Database ID: " . $row['id'] . PHP_EOL; + echo "UUID: " . $row['uuid'] . PHP_EOL; + echo "Slug: " . ($row['slug'] ?: '(empty)') . PHP_EOL; + echo "Register: " . $row['register'] . PHP_EOL; + echo "Schema: " . $row['schema'] . PHP_EOL; + echo PHP_EOL; + + // Parse the JSON object + $obj = json_decode($row['object'], true); + if ($obj) { + echo "=== OBJECT STRUCTURE ANALYSIS ===" . PHP_EOL; + + // Check @self structure + if (isset($obj['@self'])) { + echo "āœ“ @self structure:" . PHP_EOL; + foreach ($obj['@self'] as $key => $value) { + echo " $key: " . $value . PHP_EOL; + } + echo PHP_EOL; + } else { + echo "āŒ No @self structure found" . PHP_EOL; + } + + // Check for flattened properties + $expectedProps = ['GEMMA type', 'Object ID', 'GEMMA thema']; + echo "=== FLATTENED PROPERTIES CHECK ===" . PHP_EOL; + $foundProps = []; + foreach ($expectedProps as $prop) { + if (isset($obj[$prop])) { + echo "āœ“ $prop: " . $obj[$prop] . PHP_EOL; + $foundProps[] = $prop; + } else { + echo "āŒ $prop: NOT FOUND" . PHP_EOL; + } + } + echo PHP_EOL; + + // Check if properties are in xml field instead + if (isset($obj['xml']) && is_array($obj['xml'])) { + echo "=== PROPERTIES IN XML FIELD (SHOULD BE EMPTY) ===" . PHP_EOL; + $xmlProps = []; + foreach ($expectedProps as $prop) { + if (isset($obj['xml'][$prop])) { + echo "āš ļø $prop found in xml field: " . $obj['xml'][$prop] . PHP_EOL; + $xmlProps[] = $prop; + } + } + if (empty($xmlProps)) { + echo "āœ“ No flattened properties found in xml field (good!)" . PHP_EOL; + } + } + + echo PHP_EOL . "=== SUMMARY ===" . PHP_EOL; + echo "Root level properties found: " . count($foundProps) . "/" . count($expectedProps) . PHP_EOL; + echo "Database slug field: " . ($row['slug'] ? "āœ“ " . $row['slug'] : "āŒ EMPTY") . PHP_EOL; + echo "@self.slug: " . (isset($obj['@self']['slug']) ? "āœ“ " . $obj['@self']['slug'] : "āŒ NOT SET") . PHP_EOL; + } else { + echo "āŒ Failed to parse object JSON" . PHP_EOL; + } + } else { + echo "āŒ Test object not found" . PHP_EOL; + } +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . PHP_EOL; +} + diff --git a/full_circle_test.php b/full_circle_test.php index b33789e8..fce20813 100644 --- a/full_circle_test.php +++ b/full_circle_test.php @@ -22,7 +22,7 @@ // Configuration $baseUrl = 'http://localhost/index.php/apps/softwarecatalog/api/archimate'; $auth = 'admin:admin'; -$testFile = '/var/www/html/apps-extra/softwarecatalog/lib/Settings/GEMMA_release.xml'; +$testFile = '/var/www/html/apps-extra/softwarecatalog/lib/Settings/GEMMA_smaller.xml'; // Target object focus (optional) e.g. --object=. If omitted, no single-object focus is performed. $targetObjectId = null; @@ -159,8 +159,11 @@ function findInExportedXml(string $xmlPath, string $identifier): array { // Step 2: Import GEMMA_release.xml echo "2ļøāƒ£ IMPORTING GEMMA_RELEASE.XML\n"; echo "-------------------------------\n"; - $importData = json_encode(['file_path' => $testFile]); - $importResult = executeCurl("$baseUrl/import", 'POST', $importData); + + // Use multipart file upload instead of JSON file_path + $uploadCmd = "curl -s -u $auth -X POST \"$baseUrl/import\" -F \"archiMateFile=@$testFile\" -F \"replaceExisting=true\""; + $importResponse = shell_exec($uploadCmd); + $importResult = json_decode($importResponse, true); if (!$importResult || !($importResult['success'] ?? false)) { throw new Exception("Import failed: " . json_encode($importResult)); diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 77ae05ca..8f680dea 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -17,17 +17,30 @@ namespace OCA\SoftwareCatalog\AppInfo; +use OCA\SoftwareCatalog\Service\SoftwareCatalogue\ContactPersonHandler; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCA\SoftwareCatalog\EventListener\SoftwareCatalogEventListener; +use OCA\SoftwareCatalog\EventListener\TestEventListener; + use OCA\OpenRegister\Event\ObjectCreatedEvent; use OCA\OpenRegister\Event\ObjectUpdatedEvent; use OCA\OpenRegister\Event\ObjectDeletedEvent; use OCA\OpenRegister\Event\ObjectLockedEvent; use OCA\OpenRegister\Event\ObjectUnlockedEvent; use OCA\OpenRegister\Event\ObjectRevertedEvent; +use OCA\OpenRegister\Event\OrganisationCreatedEvent; +use OCA\OpenRegister\Event\RegisterCreatedEvent; +use OCA\OpenRegister\Event\RegisterDeletedEvent; +use OCA\OpenRegister\Event\RegisterUpdatedEvent; +use OCA\OpenRegister\Event\SchemaCreatedEvent; +use OCA\OpenRegister\Event\SchemaDeletedEvent; +use OCA\OpenRegister\Event\SchemaUpdatedEvent; +use OCP\User\Events\UserLoggedInEvent; +use OCP\IConfig; +use OCP\IDBConnection; use OCP\IUserManager; use OCP\IGroupManager; use OCP\IAppConfig; @@ -37,6 +50,7 @@ use Psr\Container\ContainerInterface; use OCA\SoftwareCatalog\Service\SymfonyEmailService; use OCA\SoftwareCatalog\Service\SettingsService; +use OCA\SoftwareCatalog\Service\GebruikSyncService; /** * Main Application class for SoftwareCatalog @@ -67,13 +81,13 @@ public function __construct() * Register event listeners and services * * @param IRegistrationContext $context Registration context - * + * * @return void */ public function register(IRegistrationContext $context): void { include_once __DIR__ . '/../../vendor/autoload.php'; - + // Register the handlers as services $context->registerService('OCA\SoftwareCatalog\Service\SoftwareCatalogue\OrganizationHandler', function (ContainerInterface $c) { return new \OCA\SoftwareCatalog\Service\SoftwareCatalogue\OrganizationHandler( @@ -94,7 +108,8 @@ public function register(IRegistrationContext $context): void $c, $c->get(IAppManager::class), $c->get(\Psr\Log\LoggerInterface::class), - $c->get(SymfonyEmailService::class) + $c->get(SymfonyEmailService::class), + $c->get(IConfig::class) ); }); @@ -117,6 +132,11 @@ public function register(IRegistrationContext $context): void ); }); + + + // Register TEST event listener for easily triggerable Nextcloud events + $context->registerEventListener(UserLoggedInEvent::class, TestEventListener::class); + // Register event listeners for OpenRegister events $context->registerEventListener(ObjectCreatedEvent::class, SoftwareCatalogEventListener::class); $context->registerEventListener(ObjectUpdatedEvent::class, SoftwareCatalogEventListener::class); @@ -124,10 +144,12 @@ public function register(IRegistrationContext $context): void $context->registerEventListener(ObjectLockedEvent::class, SoftwareCatalogEventListener::class); $context->registerEventListener(ObjectUnlockedEvent::class, SoftwareCatalogEventListener::class); $context->registerEventListener(ObjectRevertedEvent::class, SoftwareCatalogEventListener::class); - + + + // Organization event listeners removed - now using cron job for organization synchronization // Contact person event listeners are still active for real-time processing - + // Register new focused services $context->registerService(\OCA\SoftwareCatalog\Service\OrganisatieService::class, function ($container) { return new \OCA\SoftwareCatalog\Service\OrganisatieService( @@ -135,7 +157,9 @@ public function register(IRegistrationContext $context): void $container->get('Psr\Log\LoggerInterface'), $container, $container->get('OCP\App\IAppManager'), - $container->get(IAppConfig::class) + $container->get(IAppConfig::class), + $container->get(IUserManager::class), + $container->get(SymfonyEmailService::class), ); }); @@ -179,15 +203,34 @@ public function register(IRegistrationContext $context): void $container->get(\OCA\SoftwareCatalog\Service\ContactpersoonService::class), $container->get(SymfonyEmailService::class), $container->get(IAppConfig::class), + $container->get('Psr\Log\LoggerInterface'), + $container->get(SettingsService::class), + $container->get(IDBConnection::class), + $container->get(ContactPersonHandler::class), + ); + }); + + // Register gebruik sync service + $context->registerService(\OCA\SoftwareCatalog\Service\GebruikSyncService::class, function ($container) { + return new \OCA\SoftwareCatalog\Service\GebruikSyncService( $container->get('Psr\Log\LoggerInterface'), $container->get(SettingsService::class) ); }); + // Event listener uses direct service access like OpenCatalogi - no service registration needed + // Register ArchiMate import service $context->registerService(\OCA\SoftwareCatalog\Service\ArchiMateImportService::class, function ($container) { return new \OCA\SoftwareCatalog\Service\ArchiMateImportService( - $container->get('Psr\Log\LoggerInterface') + $container->get(IAppConfig::class), + $container->get('OCP\Files\IRootFolder'), + $container->get('OCP\IUserSession'), + $container->get('OCP\App\IAppManager'), + $container, + $container->get('Psr\Log\LoggerInterface'), + $container->get(SettingsService::class), + $container->get(\OCA\OpenRegister\Service\OrganisationService::class) ); }); @@ -207,11 +250,24 @@ public function register(IRegistrationContext $context): void $container->get('OCP\App\IAppManager'), $container, $container->get('Psr\Log\LoggerInterface'), + $container->get(SettingsService::class), $container->get(\OCA\SoftwareCatalog\Service\ArchiMateImportService::class), $container->get(\OCA\SoftwareCatalog\Service\ArchiMateExportService::class) ); }); + // Register View service for ArchiMate views with enrichment capabilities + $context->registerService(\OCA\SoftwareCatalog\Service\ViewService::class, function ($container) { + return new \OCA\SoftwareCatalog\Service\ViewService( + $container->get(IAppConfig::class), + $container->get('OCP\App\IAppManager'), + $container, + $container->get('Psr\Log\LoggerInterface'), + $container->get(SettingsService::class), + $container->get('OCP\IUserSession') + ); + }); + // Register progress tracking service $context->registerService(\OCA\SoftwareCatalog\Service\ProgressTracker::class, function ($container) { return new \OCA\SoftwareCatalog\Service\ProgressTracker( @@ -233,14 +289,14 @@ public function register(IRegistrationContext $context): void * Boot the application * * @param IBootContext $context Boot context - * + * * @return void */ public function boot(IBootContext $context): void { $container = $context->getServerContainer(); $logger = $container->get(LoggerInterface::class); - + try { $config = $container->get(IAppConfig::class); $appManager = $container->get(IAppManager::class); @@ -256,7 +312,7 @@ public function boot(IBootContext $context): void // Check if we actually have a valid configuration, not just version matching $needsInitialization = false; $initReason = ''; - + if ($lastInitializedVersion !== $currentAppVersion || empty($lastInitializedVersion)) { $needsInitialization = true; $initReason = empty($lastInitializedVersion) ? 'never_initialized' : 'version_changed'; @@ -264,7 +320,7 @@ public function boot(IBootContext $context): void // Even if version matches, check if we have valid configuration $hasValidConfig = $config->getValueString(self::APP_ID, 'voorzieningen_organisatie_schema', '') !== '' || $config->getValueString(self::APP_ID, 'organization_schema', '') !== ''; - + if (!$hasValidConfig) { $needsInitialization = true; $initReason = 'missing_configuration'; @@ -274,25 +330,25 @@ public function boot(IBootContext $context): void ]); } } - + if ($needsInitialization) { $logger->info('SoftwareCatalog boot: Starting initialization', [ 'reason' => $initReason, 'currentVersion' => $currentAppVersion, 'lastInitializedVersion' => $lastInitializedVersion ]); - + try { $settingsService = $container->get(SettingsService::class); $initResult = $settingsService->initialize(); - + $logger->info('SoftwareCatalog boot: Initialization completed', [ 'result' => $initResult, 'hasErrors' => !empty($initResult['errors']) ]); - + // Only update version if initialization was actually successful - if (empty($initResult['errors']) && + if (empty($initResult['errors']) && ($initResult['autoConfigured'] || $initResult['fullyConfigured'])) { $config->setValueString(self::APP_ID, 'last_initialized_version', $currentAppVersion); $logger->info('SoftwareCatalog boot: Version updated to ' . $currentAppVersion . ' (successful init)'); @@ -303,7 +359,7 @@ public function boot(IBootContext $context): void 'fullyConfigured' => $initResult['fullyConfigured'] ?? false ]); } - + } catch (\RuntimeException $e) { // Don't update version if OpenRegister is not available $logger->warning('SoftwareCatalog boot: OpenRegister not available during initialization', [ @@ -320,7 +376,7 @@ public function boot(IBootContext $context): void } else { $logger->debug('SoftwareCatalog boot: Skipping initialization (version unchanged and config valid)'); } - + } catch (\Exception $e) { // Log error but don't fail the boot process $logger->error('SoftwareCatalog boot error during version check: ' . $e->getMessage(), [ @@ -357,5 +413,9 @@ public function boot(IBootContext $context): void 'exception' => $e->getMessage() ]); } + + } + + } diff --git a/lib/BackgroundJob/OrganizationContactSyncJob.php b/lib/BackgroundJob/OrganizationContactSyncJob.php index 6e28abda..67c7ebe2 100644 --- a/lib/BackgroundJob/OrganizationContactSyncJob.php +++ b/lib/BackgroundJob/OrganizationContactSyncJob.php @@ -26,7 +26,8 @@ * Background job for comprehensive organization and contact person synchronization * * This job runs every 5 minutes to ensure data consistency between SoftwareCatalog objects - * and OpenRegister entities. All business logic is delegated to the OrganizationSyncService. + * and OpenRegister entities using full sync (all organizations). All business logic is + * delegated to the OrganizationSyncService. * * @category BackgroundJob * @package OCA\SoftwareCatalog\BackgroundJob diff --git a/lib/Controller/AangebodenGebruikController.php b/lib/Controller/AangebodenGebruikController.php new file mode 100644 index 00000000..7490f022 --- /dev/null +++ b/lib/Controller/AangebodenGebruikController.php @@ -0,0 +1,504 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version 1.0.0 + * @link https://github.com/ConductionNL/SoftwareCatalog + */ + +namespace OCA\SoftwareCatalog\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCA\SoftwareCatalog\Service\AangebodenGebruikService; +use Psr\Log\LoggerInterface; + +/** + * Controller for handling offered usage (aangeboden gebruik) API operations + * + * This controller provides REST API endpoints for managing gebruiks objects where + * the active organization is involved either as afnemer (consumer) or in deelnemers + * (participants), and for updating the @self property of gebruiks objects. + * + * @category Controller + * @package OCA\SoftwareCatalog\Controller + * @author Conduction b.v. + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version 1.0.0 + * @link https://github.com/ConductionNL/SoftwareCatalog + */ +class AangebodenGebruikController extends Controller +{ + /** + * Constructor for AangebodenGebruikController + * + * @param string $appName The name of the app + * @param IRequest $request The HTTP request object + * @param AangebodenGebruikService $aangebodenGebruikService The business logic service + * @param LoggerInterface $logger The logger service for debugging and error reporting + */ + public function __construct( + string $appName, + IRequest $request, + private readonly AangebodenGebruikService $aangebodenGebruikService, + private readonly LoggerInterface $logger + ) { + parent::__construct($appName, $request); + } + + /** + * Get all gebruiks objects where the active organization is the afnemer (consumer) + * + * API Endpoint: GET /api/aangeboden-gebruik/afnemer + * + * Query Parameters: + * - limit (int): Maximum number of results to return + * - offset (int): Number of results to skip for pagination + * - status (string): Filter by usage status + * - product (string): Filter by product ID + * - startDate (string): Filter by start date (ISO 8601 format) + * - endDate (string): Filter by end date (ISO 8601 format) + * + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @return JSONResponse JSON response with gebruiks array where org is afnemer + */ + public function getGebruiksWhereAfnemer(): JSONResponse + { + $this->logger->info('API: Getting gebruiks where active org is afnemer', [ + 'endpoint' => '/api/aangeboden-gebruik/afnemer', + 'method' => 'GET', + 'query_params' => $this->request->getParams() + ]); + + try { + // Parse query parameters for filtering options + $options = $this->parseQueryOptions(); + + // Get gebruiks from service where org is afnemer + $result = $this->aangebodenGebruikService->getGebruiksWhereAfnemer($options); + + // Determine appropriate HTTP status code + $statusCode = $result['success'] ? 200 : 500; + + $this->logger->info('API: Afnemer gebruiks request completed', [ + 'success' => $result['success'], + 'gebruiks_count' => $result['count'] ?? 0, + 'organisation' => $result['organisation'] ?? 'unknown' + ]); + + return new JSONResponse($result, $statusCode); + + } catch (\Exception $e) { + $this->logger->error('API: Failed to get afnemer gebruiks', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JSONResponse([ + 'success' => false, + 'error' => 'Internal server error: ' . $e->getMessage(), + 'gebruiks' => [], + 'count' => 0 + ], 500); + } + } + + /** + * Get all gebruiks objects where the active organization is in deelnemers (participants) + * + * API Endpoint: GET /api/aangeboden-gebruik/deelnemers + * + * Query Parameters: + * - limit (int): Maximum number of results to return + * - offset (int): Number of results to skip for pagination + * - status (string): Filter by usage status + * - product (string): Filter by product ID + * - startDate (string): Filter by start date (ISO 8601 format) + * - endDate (string): Filter by end date (ISO 8601 format) + * + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @return JSONResponse JSON response with gebruiks array where org is in deelnemers + */ + public function getGebruiksWhereDeelnemers(): JSONResponse + { + $this->logger->info('API: Getting gebruiks where active org is in deelnemers', [ + 'endpoint' => '/api/aangeboden-gebruik/deelnemers', + 'method' => 'GET', + 'query_params' => $this->request->getParams() + ]); + + try { + // Parse query parameters for filtering options + $options = $this->parseQueryOptions(); + + // Get gebruiks from service where org is in deelnemers + $result = $this->aangebodenGebruikService->getGebruiksWhereDeelnemers($options); + + // Determine appropriate HTTP status code + $statusCode = $result['success'] ? 200 : 500; + + $this->logger->info('API: Deelnemers gebruiks request completed', [ + 'success' => $result['success'], + 'gebruiks_count' => $result['count'] ?? 0, + 'organisation' => $result['organisation'] ?? 'unknown' + ]); + + return new JSONResponse($result, $statusCode); + + } catch (\Exception $e) { + $this->logger->error('API: Failed to get deelnemers gebruiks', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JSONResponse([ + 'success' => false, + 'error' => 'Internal server error: ' . $e->getMessage(), + 'gebruiks' => [], + 'count' => 0 + ], 500); + } + } + + /** + * Set the @self property of a gebruik to the active organization + * + * API Endpoint: PUT /api/aangeboden-gebruik/{gebruikId}/set-self + * + * This endpoint allows setting the @self.organisation property of a specific gebruik + * object to the active organization, but only if the active organization is the + * afnemer (consumer) for that gebruik. + * + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @param string $gebruikId The UUID of the gebruik object to update + * @return JSONResponse JSON response with success status and updated object + */ + public function setGebruikSelfToActiveOrg(string $gebruikId): JSONResponse + { + $this->logger->info('API: Setting gebruik @self property to active org', [ + 'endpoint' => "/api/aangeboden-gebruik/{$gebruikId}/set-self", + 'method' => 'PUT', + 'gebruik_id' => $gebruikId + ]); + + try { + // Validate input + if (empty($gebruikId)) { + return new JSONResponse([ + 'success' => false, + 'error' => 'Gebruik ID is required', + 'gebruik' => null + ], 400); + } + + // Parse any additional options from request body + $options = []; + $requestBody = $this->request->getParams(); + if (!empty($requestBody)) { + $options = array_filter($requestBody, function($key) { + return !in_array($key, ['gebruikId']); // Exclude path parameters + }, ARRAY_FILTER_USE_KEY); + } + + // Update gebruik @self property via service + $result = $this->aangebodenGebruikService->setGebruikSelfToActiveOrg($gebruikId, $options); + + // Determine appropriate HTTP status code + $statusCode = $result['success'] ? 200 : ($result['error'] === 'Gebruik object not found' ? 404 : + ($result['error'] === 'Operation not allowed: active organization is not the afnemer' ? 403 : 500)); + + $this->logger->info('API: Set gebruik @self property request completed', [ + 'gebruik_id' => $gebruikId, + 'success' => $result['success'], + 'status_code' => $statusCode + ]); + + return new JSONResponse($result, $statusCode); + + } catch (\Exception $e) { + $this->logger->error('API: Failed to set gebruik @self property', [ + 'gebruik_id' => $gebruikId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JSONResponse([ + 'success' => false, + 'error' => 'Internal server error: ' . $e->getMessage(), + 'gebruik' => null + ], 500); + } + } + + /** + * Get API documentation for AangebodenGebruik endpoints + * + * API Endpoint: GET /api/aangeboden-gebruik/docs + * + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @return JSONResponse JSON response with API documentation + */ + public function getApiDocumentation(): JSONResponse + { + $documentation = [ + 'api_version' => '1.0.0', + 'description' => 'SoftwareCatalog AangebodenGebruik API - Manage gebruiks objects where active organization is involved', + 'base_url' => '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/api/aangeboden-gebruik', + 'endpoints' => [ + [ + 'method' => 'GET', + 'path' => '/api/aangeboden-gebruik/afnemer', + 'description' => 'Get all gebruiks objects where the active organization is the afnemer (consumer)', + 'parameters' => [ + [ + 'name' => 'limit', + 'type' => 'integer', + 'required' => false, + 'description' => 'Maximum number of results to return' + ], + [ + 'name' => 'offset', + 'type' => 'integer', + 'required' => false, + 'description' => 'Number of results to skip for pagination' + ], + [ + 'name' => 'status', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by usage status' + ], + [ + 'name' => 'product', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by product ID' + ], + [ + 'name' => 'startDate', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by start date (ISO 8601 format)' + ], + [ + 'name' => 'endDate', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by end date (ISO 8601 format)' + ] + ], + 'response_example' => [ + 'success' => true, + 'gebruiks' => [ + [ + 'id' => 'usage-uuid-123', + 'afnemer' => 'org-uuid', + 'product' => 'product-uuid', + 'status' => 'actief', + '_filter_type' => 'afnemer', + '_schema_id' => 'schema-id' + ] + ], + 'count' => 1, + 'filter_type' => 'afnemer', + 'organisation' => 'org-uuid' + ] + ], + [ + 'method' => 'GET', + 'path' => '/api/aangeboden-gebruik/deelnemers', + 'description' => 'Get all gebruiks objects where the active organization is in deelnemers (participants)', + 'parameters' => [ + [ + 'name' => 'limit', + 'type' => 'integer', + 'required' => false, + 'description' => 'Maximum number of results to return' + ], + [ + 'name' => 'offset', + 'type' => 'integer', + 'required' => false, + 'description' => 'Number of results to skip for pagination' + ], + [ + 'name' => 'status', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by usage status' + ], + [ + 'name' => 'product', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by product ID' + ], + [ + 'name' => 'startDate', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by start date (ISO 8601 format)' + ], + [ + 'name' => 'endDate', + 'type' => 'string', + 'required' => false, + 'description' => 'Filter by end date (ISO 8601 format)' + ] + ], + 'response_example' => [ + 'success' => true, + 'gebruiks' => [ + [ + 'id' => 'usage-uuid-456', + 'afnemer' => 'other-org-uuid', + 'deelnemers' => ['org-uuid', 'another-org-uuid'], + 'product' => 'product-uuid', + 'status' => 'actief', + '_filter_type' => 'deelnemers', + '_schema_id' => 'schema-id' + ] + ], + 'count' => 1, + 'filter_type' => 'deelnemers', + 'organisation' => 'org-uuid' + ] + ], + [ + 'method' => 'PUT', + 'path' => '/api/aangeboden-gebruik/{gebruikId}/set-self', + 'description' => 'Set the @self property of a gebruik to the active organization (only allowed if active org is afnemer)', + 'parameters' => [ + [ + 'name' => 'gebruikId', + 'type' => 'string', + 'required' => true, + 'description' => 'The UUID of the gebruik object to update (in URL path)' + ] + ], + 'response_example' => [ + 'success' => true, + 'message' => 'Gebruik @self property updated successfully', + 'gebruik' => [ + 'id' => 'usage-uuid-123', + 'afnemer' => 'org-uuid', + '@self' => [ + 'organisation' => 'org-uuid', + 'register' => 'register-id', + 'schema' => 'schema-id' + ] + ], + 'updated_fields' => ['@self.organisation'] + ] + ], + [ + 'method' => 'GET', + 'path' => '/api/aangeboden-gebruik/docs', + 'description' => 'Get this API documentation', + 'parameters' => [], + 'response_example' => '(this response)' + ] + ], + 'security' => [ + 'afnemer_filtering' => 'Uses standard RBAC filtering based on organization association', + 'deelnemers_filtering' => 'Uses RBAC-disabled search to find participation records', + 'self_update_permission' => 'Only allowed if active organization is the afnemer for the specific gebruik' + ], + 'error_codes' => [ + 400 => 'Bad Request - Invalid parameters or missing required fields', + 403 => 'Forbidden - Operation not allowed (e.g., org is not afnemer for @self update)', + 404 => 'Not Found - Gebruik object not found', + 500 => 'Internal Server Error - Server-side error occurred' + ] + ]; + + return new JSONResponse($documentation, 200); + } + + /** + * Parse query parameters into options array + * + * This method extracts and validates query parameters for filtering, + * pagination, and other options. + * + * @return array Parsed options array + */ + private function parseQueryOptions(): array + { + $options = []; + + // Parse pagination parameters + $limit = $this->request->getParam('limit'); + if ($limit !== null && is_numeric($limit)) { + $options['limit'] = (int)$limit; + } + + $offset = $this->request->getParam('offset'); + if ($offset !== null && is_numeric($offset)) { + $options['offset'] = (int)$offset; + } + + // Parse filter parameters + $status = $this->request->getParam('status'); + if ($status !== null && !empty(trim($status))) { + $options['status'] = trim($status); + } + + $product = $this->request->getParam('product'); + if ($product !== null && !empty(trim($product))) { + $options['product'] = trim($product); + } + + $startDate = $this->request->getParam('startDate'); + if ($startDate !== null && !empty(trim($startDate))) { + $options['startDate'] = trim($startDate); + } + + $endDate = $this->request->getParam('endDate'); + if ($endDate !== null && !empty(trim($endDate))) { + $options['endDate'] = trim($endDate); + } + + $this->logger->debug('Parsed query options for AangebodenGebruik', [ + 'raw_params' => [ + 'limit' => $limit, + 'offset' => $offset, + 'status' => $status, + 'product' => $product, + 'startDate' => $startDate, + 'endDate' => $endDate + ], + 'parsed_options' => $options + ]); + + return $options; + } +} + + diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index a1e03d2c..474b4251 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -73,6 +73,7 @@ public function __construct( private readonly LoggerInterface $logger, ) { parent::__construct($appName, $request); + $this->_appName = $appName; }//end __construct() @@ -228,6 +229,137 @@ public function create(): JSONResponse }//end create() + /** + * Get general configuration settings + * + * @NoCSRFRequired + * + * @return JSONResponse General configuration + */ + public function getGeneralConfig(): JSONResponse + { + try { + $config = [ + 'catalogLocation' => $this->settingsService->getCatalogLocation(), + ]; + + return new JSONResponse([ + 'success' => true, + 'config' => $config + ]); + + } catch (\Exception $e) { + $this->logger->error('Failed to get general config', [ + 'exception' => $e->getMessage() + ]); + return new JSONResponse([ + 'success' => false, + 'message' => 'Failed to get general config: ' . $e->getMessage() + ], 500); + } + } + + /** + * Update general configuration settings + * + * @NoCSRFRequired + * + * @return JSONResponse Update result + */ + public function updateGeneralConfig(): JSONResponse + { + try { + $data = $this->request->getParams(); + + if (isset($data['catalogLocation'])) { + $this->settingsService->setCatalogLocation($data['catalogLocation']); + } + + return new JSONResponse([ + 'success' => true, + 'message' => 'General configuration updated successfully', + 'config' => [ + 'catalogLocation' => $this->settingsService->getCatalogLocation(), + ] + ]); + + } catch (\Exception $e) { + $this->logger->error('Failed to update general config', [ + 'exception' => $e->getMessage(), + 'requestData' => $this->request->getParams() + ]); + return new JSONResponse([ + 'success' => false, + 'message' => 'Failed to update general config: ' . $e->getMessage() + ], 500); + } + } + + /** + * Get organization synchronization configuration + * + * @NoCSRFRequired + * + * @return JSONResponse Sync configuration + */ + public function getSyncConfig(): JSONResponse + { + try { + $config = [ + 'syncTimeWindow' => $this->config->getValueString($this->_appName, 'syncTimeWindow', '10'), + ]; + + return new JSONResponse([ + 'success' => true, + 'config' => $config + ]); + + } catch (\Exception $e) { + $this->logger->error('Failed to get sync config', [ + 'exception' => $e->getMessage() + ]); + return new JSONResponse([ + 'success' => false, + 'message' => 'Failed to get sync config: ' . $e->getMessage() + ], 500); + } + } + + /** + * Update organization synchronization configuration + * + * @NoCSRFRequired + * + * @return JSONResponse Update result + */ + public function updateSyncConfig(): JSONResponse + { + try { + $data = $this->request->getParams(); + + if (isset($data['syncTimeWindow'])) { + $this->config->setValueString($this->_appName, 'syncTimeWindow', (string) $data['syncTimeWindow']); + } + + return new JSONResponse([ + 'success' => true, + 'message' => 'Sync configuration updated successfully', + 'config' => [ + 'syncTimeWindow' => $this->config->getValueString($this->_appName, 'syncTimeWindow', '10'), + ] + ]); + + } catch (\Exception $e) { + $this->logger->error('Failed to update sync config', [ + 'exception' => $e->getMessage(), + 'requestData' => $this->request->getParams() + ]); + return new JSONResponse([ + 'success' => false, + 'message' => 'Failed to update sync config: ' . $e->getMessage() + ], 500); + } + } /** * Load the settings from the publication_register.json file. @@ -457,16 +589,83 @@ public function getSyncStatus(int $minutesBack = 10): JSONResponse */ public function performSync(int $minutesBack = 0): JSONResponse { - $result = $this->organizationSyncService->performManualSync($minutesBack); - - if ($result['success']) { - return new JSONResponse($result); - } else { - return new JSONResponse($result, 500); + try { + // For full sync (minutesBack = 0), use optimized batch processing to handle large datasets + if ($minutesBack === 0) { + $result = $this->organizationSyncService->performOptimizedManualSync( + maxRounds: 15, // Up to 15 rounds of processing + batchSize: 75 // 75 items per batch for good performance + ); + + return new JSONResponse([ + 'success' => true, + 'results' => $result, + 'message' => 'Optimized synchronization completed successfully', + 'isOptimized' => true + ]); + } else { + // For incremental sync, use the original method + $result = $this->organizationSyncService->performManualSync($minutesBack); + + if ($result['success']) { + return new JSONResponse($result); + } else { + return new JSONResponse($result, 500); + } + } + } catch (\Exception $e) { + $this->logger->error('Manual sync failed', [ + 'minutesBack' => $minutesBack, + 'exception' => $e->getMessage() + ]); + + return new JSONResponse([ + 'success' => false, + 'message' => 'Synchronization failed: ' . $e->getMessage(), + 'error' => $e->getMessage() + ], 500); } } - + /** + * Heartbeat endpoint to keep connections alive during long-running operations + * + * This endpoint prevents 504 gateway timeouts by responding to periodic + * keep-alive requests sent during lengthy operations like sync or export. + * + * @return JSONResponse JSON response confirming heartbeat received + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function heartbeat(): JSONResponse + { + try { + $timestamp = $this->request->getParam('timestamp', time() * 1000); + + $this->logger->debug('Heartbeat received', [ + 'timestamp' => $timestamp, + 'server_time' => time() * 1000 + ]); + + return new JSONResponse([ + 'success' => true, + 'message' => 'Heartbeat received', + 'timestamp' => $timestamp, + 'server_time' => time() * 1000 + ]); + } catch (\Exception $e) { + $this->logger->error('Heartbeat error: ' . $e->getMessage(), [ + 'exception' => $e + ]); + + return new JSONResponse([ + 'success' => false, + 'message' => 'Heartbeat failed', + 'error' => $e->getMessage() + ], 500); + } + } /** * Get version information for the app and configuration. @@ -531,6 +730,37 @@ public function resetAutoConfig(): JSONResponse } } + /** + * Clear configuration cache to force reload of schema IDs and register IDs. + * + * @return JSONResponse JSON response containing cache clear results. + * + * @NoCSRFRequired + */ + public function clearCache(): JSONResponse + { + try { + $this->logger->info('SettingsController: Clearing configuration cache'); + + $this->settingsService->clearConfigurationCache(); + + return new JSONResponse([ + 'success' => true, + 'message' => 'Configuration cache cleared successfully' + ]); + } catch (\Exception $e) { + $this->logger->error('SettingsController: Cache clear failed', [ + 'exception' => $e->getMessage() + ]); + + return new JSONResponse([ + 'success' => false, + 'message' => 'Cache clear failed: ' . $e->getMessage(), + 'error' => $e->getMessage() + ], 500); + } + } + /** * Manually trigger configuration import. * @@ -821,6 +1051,12 @@ public function render(): string public function importArchiMate(): JSONResponse { try { + // Increase memory limit for large imports + ini_set('memory_limit', '4096M'); + $this->logger->info('Memory limit increased for import', [ + 'old_limit' => ini_get('memory_limit'), + 'new_limit' => '4096M' + ]); // Get JSON data from request body $rawInput = file_get_contents('php://input'); $data = json_decode($rawInput, true); @@ -893,8 +1129,15 @@ public function importArchiMate(): JSONResponse ], 400); } - // Call the ArchiMate service with file data instead of File object - $result = $this->archiMateService->importArchiMateFileFromPath($options); + // OPTIMIZATION: Use optimized method if available or if explicitly requested + $useOptimized = $this->request->getParam('useOptimized', 'true') === 'true'; + if ($useOptimized && method_exists($this->archiMateService, 'importArchiMateFileFromPathOptimized')) { + $this->logger->info('Using OPTIMIZED ArchiMate import method'); + $result = $this->archiMateService->importArchiMateFileFromPathOptimized($options); + } else { + $this->logger->info('Using STANDARD ArchiMate import method'); + $result = $this->archiMateService->importArchiMateFileFromPath($options); + } return new JSONResponse($result); @@ -904,11 +1147,14 @@ public function importArchiMate(): JSONResponse 'trace' => $e->getTraceAsString() ]); + // Determine appropriate HTTP status code based on error type + $statusCode = $this->getHttpStatusForException($e); + return new JSONResponse([ 'success' => false, 'message' => 'Import failed: ' . $e->getMessage(), 'error' => $e->getMessage() - ], 500); + ], $statusCode); } } @@ -942,11 +1188,14 @@ public function exportArchiMate(): Response // Check if export was successful if (!$result['success']) { + // Determine appropriate status code based on error message + $statusCode = $this->getHttpStatusForErrorMessage($result['error'] ?? 'Export failed'); + return new JSONResponse([ 'success' => false, 'message' => $result['error'] ?? 'Export failed', 'error' => $result['error'] ?? 'EXPORT_FAILED' - ], 500); + ], $statusCode); } // Return the XML file directly for download @@ -987,11 +1236,14 @@ public function render(): string { 'trace' => $e->getTraceAsString() ]); + // Determine appropriate HTTP status code based on error type + $statusCode = $this->getHttpStatusForException($e); + return new JSONResponse([ 'success' => false, 'message' => 'Export failed: ' . $e->getMessage(), 'error' => $e->getMessage() - ], 500); + ], $statusCode); } } @@ -2129,6 +2381,63 @@ public function updateUserGroupsConfig(): JSONResponse } } + /** + * Determine appropriate HTTP status code for an exception + * + * @param \Exception $e The exception to classify + * @return int HTTP status code (400, 404, 422, or 500) + */ + private function getHttpStatusForException(\Exception $e): int + { + // Check exception type first + if ($e instanceof \InvalidArgumentException) { + return 400; // Bad Request for invalid arguments/configuration + } + + // Fallback to message-based classification + $message = $e->getMessage(); + return $this->getHttpStatusForErrorMessage($message); + } + + /** + * Determine appropriate HTTP status code for an error message + * + * @param string $message The error message to classify + * @return int HTTP status code (400, 404, 422, or 500) + */ + private function getHttpStatusForErrorMessage(string $message): int + { + $message = strtolower($message); + + // Configuration errors - 400 Bad Request + if (str_contains($message, 'not configured') || + str_contains($message, 'missing configuration') || + str_contains($message, 'invalid configuration')) { + return 400; + } + + // File not found errors - 404 Not Found + if (str_contains($message, 'file not found') || + str_contains($message, 'not found') || + str_contains($message, 'missing file')) { + return 404; + } + + // Validation errors - 422 Unprocessable Entity + if (str_contains($message, 'validation') || + str_contains($message, 'invalid xml') || + str_contains($message, 'parsing error') || + str_contains($message, 'malformed') || + str_contains($message, 'could not be parsed')) { + return 422; + } + + // Default to 500 Internal Server Error for unknown issues + return 500; + } + + + }//end class diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php new file mode 100644 index 00000000..c3b22a58 --- /dev/null +++ b/lib/Controller/ViewController.php @@ -0,0 +1,388 @@ +logger->info('API: Getting all views', [ + 'endpoint' => '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/api/views', + 'method' => 'GET', + 'query_params' => $this->request->getParams() + ]); + + try { + // Parse query parameters for enrichment options + $options = $this->parseEnrichmentOptions(); + + // Get views from service with enrichments + $result = $this->viewService->getAllViews($options); + + // Return appropriate HTTP status code + $statusCode = $result['success'] ? 200 : 500; + + $this->logger->info('API: All views request completed', [ + 'success' => $result['success'], + 'views_count' => $result['count'] ?? 0, + 'enrichments_applied' => $result['enrichments_applied'] ?? [] + ]); + + return new JSONResponse($result, $statusCode); + + } catch (\Exception $e) { + $this->logger->error('API: Failed to get all views', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JSONResponse([ + 'success' => false, + 'error' => 'Internal server error: ' . $e->getMessage(), + 'views' => [], + 'count' => 0 + ], 500); + } + } + + /** + * Get a specific view by ID with optional enrichment + * + * API Endpoint: GET /api/views/{viewId} + * + * Query Parameters: + * - include_products (bool): Include product data in view nodes + * - include_modules (bool): Include module data in view nodes (linked via elementRef) + * - include_gebruik (bool): Include usage data in view nodes + * - include_deelnames_gebruik (bool): Include participation usage data in view nodes + * + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @param string $viewId The view identifier + * @return JSONResponse JSON response with view object + */ + public function getView(string $viewId): JSONResponse + { + $this->logger->info('API: Getting specific view', [ + 'endpoint' => "/api/views/{$viewId}", + 'method' => 'GET', + 'view_id' => $viewId, + 'query_params' => $this->request->getParams() + ]); + + try { + // Validate view ID + if (empty($viewId)) { + return new JSONResponse([ + 'success' => false, + 'error' => 'View ID is required', + 'view' => null + ], 400); + } + + // Parse query parameters for enrichment options + $options = $this->parseEnrichmentOptions(); + + // Get view from service with enrichments + $result = $this->viewService->getView($viewId, $options); + + // Return appropriate HTTP status code + $statusCode = $result['success'] ? 200 : ($result['view'] === null ? 404 : 500); + + $this->logger->info('API: Specific view request completed', [ + 'view_id' => $viewId, + 'success' => $result['success'], + 'found' => $result['view'] !== null, + 'enrichments_applied' => $result['enrichments_applied'] ?? [] + ]); + + return new JSONResponse($result, $statusCode); + + } catch (\Exception $e) { + $this->logger->error('API: Failed to get specific view', [ + 'view_id' => $viewId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JSONResponse([ + 'success' => false, + 'error' => 'Internal server error: ' . $e->getMessage(), + 'view' => null + ], 500); + } + } + + /** + * Parse enrichment options from query parameters + * + * @return array Parsed enrichment options + */ + private function parseEnrichmentOptions(): array + { + $options = []; + + // Parse boolean query parameters + $includeProducts = $this->request->getParam('include_products'); + if ($includeProducts !== null) { + $options['include_products'] = $this->parseBooleanParam($includeProducts); + } + + $includeModules = $this->request->getParam('include_modules'); + if ($includeModules !== null) { + $options['include_modules'] = $this->parseBooleanParam($includeModules); + } + + $includeGebruik = $this->request->getParam('include_gebruik'); + if ($includeGebruik !== null) { + $options['include_gebruik'] = $this->parseBooleanParam($includeGebruik); + } + + $includeDeelnamesGebruik = $this->request->getParam('include_deelnames_gebruik'); + if ($includeDeelnamesGebruik !== null) { + $options['include_deelnames_gebruik'] = $this->parseBooleanParam($includeDeelnamesGebruik); + } + + $this->logger->debug('Parsed enrichment options', [ + 'raw_params' => [ + 'include_products' => $includeProducts, + 'include_modules' => $includeModules, + 'include_gebruik' => $includeGebruik, + 'include_deelnames_gebruik' => $includeDeelnamesGebruik + ], + 'parsed_options' => $options + ]); + + return $options; + } + + /** + * Parse a boolean parameter from string values + * + * Accepts: true, false, 1, 0, "true", "false", "1", "0", "yes", "no" + * + * @param mixed $value The parameter value to parse + * @return bool The parsed boolean value + */ + private function parseBooleanParam($value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int)$value > 0; + } + + if (is_string($value)) { + $lowerValue = strtolower(trim($value)); + return in_array($lowerValue, ['true', '1', 'yes', 'on'], true); + } + + return false; + } + + /** + * Get API documentation for view endpoints + * + * API Endpoint: GET /api/views/docs + * + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @return JSONResponse JSON response with API documentation + */ + public function getApiDocumentation(): JSONResponse + { + $documentation = [ + 'api_version' => '1.0.0', + 'description' => 'SoftwareCatalog View API - Query and enrich ArchiMate views with additional data', + 'base_url' => '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/api/views', + 'endpoints' => [ + [ + 'method' => 'GET', + 'path' => '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/api/views', + 'description' => 'Get all views with optional enrichment', + 'parameters' => [ + [ + 'name' => 'include_products', + 'type' => 'boolean', + 'required' => false, + 'description' => 'Include product data in view nodes' + ], + [ + 'name' => 'include_gebruik', + 'type' => 'boolean', + 'required' => false, + 'description' => 'Include usage data in view nodes' + ], + [ + 'name' => 'include_deelnames_gebruik', + 'type' => 'boolean', + 'required' => false, + 'description' => 'Include participation usage data in view nodes' + ] + ], + 'response_example' => [ + 'success' => true, + 'views' => [ + [ + 'id' => 'view-lv01', + 'name' => 'LV01 BGT basisregistratie en SVB view', + 'documentation' => 'Mocked from GEMMA LV01.', + 'viewNodes' => [], + 'viewRelationships' => [] + ] + ], + 'count' => 1, + 'enrichments_applied' => [] + ] + ], + [ + 'method' => 'GET', + 'path' => '/api/views/{viewId}', + 'description' => 'Get a specific view by ID with optional enrichment', + 'parameters' => [ + [ + 'name' => 'viewId', + 'type' => 'string', + 'required' => true, + 'description' => 'The view identifier (in URL path)' + ], + [ + 'name' => 'include_products', + 'type' => 'boolean', + 'required' => false, + 'description' => 'Include product data in view nodes' + ], + [ + 'name' => 'include_gebruik', + 'type' => 'boolean', + 'required' => false, + 'description' => 'Include usage data in view nodes' + ], + [ + 'name' => 'include_deelnames_gebruik', + 'type' => 'boolean', + 'required' => false, + 'description' => 'Include participation usage data in view nodes' + ] + ], + 'response_example' => [ + 'success' => true, + 'view' => [ + 'id' => 'view-lv01', + 'name' => 'LV01 BGT basisregistratie en SVB view', + 'documentation' => 'Mocked from GEMMA LV01.', + 'viewNodes' => [], + 'viewRelationships' => [] + ], + 'enrichments_applied' => [] + ] + ], + [ + 'method' => 'GET', + 'path' => '/api/views/docs', + 'description' => 'Get this API documentation', + 'parameters' => [], + 'response_example' => '(this response)' + ] + ], + 'enrichment_options' => [ + [ + 'name' => 'products', + 'description' => 'Adds product information to view nodes that reference elements with product associations' + ], + [ + 'name' => 'gebruik', + 'description' => 'Adds usage/usage statistics to view nodes' + ], + [ + 'name' => 'deelnames_gebruik', + 'description' => 'Adds participation-based usage data to view nodes created from participation records' + ] + ], + 'boolean_parameter_formats' => [ + 'accepted_true_values' => ['true', '1', 'yes', 'on', true, 1], + 'accepted_false_values' => ['false', '0', 'no', 'off', false, 0], + 'examples' => [ + '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/api/views?include_products=true', + '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/api/views?include_gebruik=1&include_products=false', + '/api/views/view-123?include_deelnames_gebruik=yes' + ] + ] + ]; + + return new JSONResponse($documentation, 200); + } +} diff --git a/lib/EventListener/OpenRegisterEventsDebugListener.php b/lib/EventListener/OpenRegisterEventsDebugListener.php new file mode 100644 index 00000000..a9601d73 --- /dev/null +++ b/lib/EventListener/OpenRegisterEventsDebugListener.php @@ -0,0 +1,367 @@ + + * @copyright 2024 Conduction B.V. + * @license AGPL-3.0-or-later https://www.gnu.org/licenses/agpl-3.0.html + * + * @version GIT: + * + * @link https://SoftwareCatalog.app + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\EventListener; + +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\OpenRegister\Event\ObjectDeletedEvent; +use OCA\OpenRegister\Event\ObjectLockedEvent; +use OCA\OpenRegister\Event\ObjectRevertedEvent; +use OCA\OpenRegister\Event\ObjectUnlockedEvent; +use OCA\OpenRegister\Event\ObjectUpdatedEvent; +use OCA\OpenRegister\Event\OrganisationCreatedEvent; +use OCA\OpenRegister\Event\RegisterCreatedEvent; +use OCA\OpenRegister\Event\RegisterDeletedEvent; +use OCA\OpenRegister\Event\RegisterUpdatedEvent; +use OCA\OpenRegister\Event\SchemaCreatedEvent; +use OCA\OpenRegister\Event\SchemaDeletedEvent; +use OCA\OpenRegister\Event\SchemaUpdatedEvent; +use Psr\Log\LoggerInterface; + +/** + * Debug event listener for all OpenRegister events in SoftwareCatalog + * + * This listener provides comprehensive debugging information for all OpenRegister events + * received by the SoftwareCatalog app. It logs event details at info level and can be + * easily enabled/disabled. + * + * @template T of Event + * + * @implements IEventListener + */ +class OpenRegisterEventsDebugListener implements IEventListener +{ + + /** + * Logger instance for debug logging + * + * @var LoggerInterface + */ + private readonly LoggerInterface $logger; + + /** + * Whether debug logging is enabled + * + * @var bool + */ + private readonly bool $debugEnabled; + + + /** + * Constructor for the debug listener + * + * @param LoggerInterface $logger Logger instance for debug output + * @param bool $debugEnabled Whether debug logging should be enabled + * + * @return void + */ + public function __construct( + LoggerInterface $logger, + bool $debugEnabled = true + ) { + $this->logger = $logger; + $this->debugEnabled = $debugEnabled; + + }//end __construct() + + + /** + * Handle any OpenRegister event for debugging purposes + * + * This method processes all OpenRegister events and logs detailed debug information + * including event type, object details, and any relevant metadata. + * + * @param Event $event The event to handle + * + * @return void + * + * @phpstan-param T $event + */ + public function handle(Event $event): void + { + // CRITICAL: Always log regardless of debug flag to ensure we see if it's called + $eventClass = get_class($event); + $eventType = $this->getEventTypeName($eventClass); + + $this->logger->critical('šŸ” SOFTWARECATALOG: OPENREGISTER DEBUG LISTENER TRIGGERED!', [ + 'app' => 'softwarecatalog', + 'eventType' => $eventType, + 'eventClass' => $eventClass, + 'listenerClass' => self::class, + 'debugEnabled' => $this->debugEnabled, + 'timestamp' => date('Y-m-d H:i:s'), + 'microtime' => microtime(true), + 'source' => 'OpenRegister', + ]); + + + + if (!$this->debugEnabled) { + $this->logger->warning('SoftwareCatalog OpenRegister Debug: Debug disabled, skipping detailed logging'); + + return; + } + + $eventData = $this->extractEventData($event); + + // Log comprehensive debug information + $this->logger->info( + '[SoftwareCatalog] šŸ” OPENREGISTER EVENT: {eventType} received from OpenRegister', + [ + 'app' => 'softwarecatalog', + 'eventType' => $eventType, + 'eventClass' => $eventClass, + 'listenerClass' => self::class, + 'eventData' => $eventData, + 'timestamp' => date('Y-m-d H:i:s'), + 'source' => 'OpenRegister', + ] + ); + + }//end handle() + + + /** + * Extract a human-readable event type name from the class name + * + * @param string $eventClass The full event class name + * + * @return string The simplified event type name + * + * @phpstan-return string + * @psalm-return string + */ + private function getEventTypeName(string $eventClass): string + { + // Extract the class name without namespace + $className = substr($eventClass, strrpos($eventClass, '\\') + 1); + + // Remove 'Event' suffix if present + if (str_ends_with($className, 'Event')) { + $className = substr($className, 0, -5); + } + + return $className; + + }//end getEventTypeName() + + + /** + * Extract relevant data from the event for debugging + * + * This method extracts useful information from different event types + * to provide comprehensive debug logging. + * + * @param Event $event The event to extract data from + * + * @return array Array of extracted event data + * + * @phpstan-return array + * @psalm-return array + */ + private function extractEventData(Event $event): array + { + $data = [ + 'eventClass' => get_class($event), + ]; + + // Handle Object events + if ($event instanceof ObjectCreatedEvent) { + $object = $event->getObject(); + $data = array_merge($data, [ + 'eventType' => 'ObjectCreated', + 'objectId' => $object->getId(), + 'objectUuid' => $object->getUuid(), + 'registerId' => $object->getRegister(), + 'schemaId' => $object->getSchema(), + 'owner' => $object->getOwner(), + 'created' => $object->getCreated()?->format('Y-m-d H:i:s'), + 'objectData' => $this->getSafeObjectData($object->getObject()), + ]); + } else if ($event instanceof ObjectUpdatedEvent) { + $newObject = $event->getNewObject(); + $oldObject = $event->getOldObject(); + $data = array_merge($data, [ + 'eventType' => 'ObjectUpdated', + 'newObjectId' => $newObject->getId(), + 'newObjectUuid' => $newObject->getUuid(), + 'oldObjectId' => $oldObject?->getId(), + 'oldObjectUuid' => $oldObject?->getUuid(), + 'registerId' => $newObject->getRegister(), + 'schemaId' => $newObject->getSchema(), + 'owner' => $newObject->getOwner(), + 'updated' => $newObject->getUpdated()?->format('Y-m-d H:i:s'), + 'newObjectData' => $this->getSafeObjectData($newObject->getObject()), + 'oldObjectData' => $oldObject ? $this->getSafeObjectData($oldObject->getObject()) : null, + ]); + } else if ($event instanceof ObjectDeletedEvent) { + $object = $event->getObject(); + $data = array_merge($data, [ + 'eventType' => 'ObjectDeleted', + 'objectId' => $object->getId(), + 'objectUuid' => $object->getUuid(), + 'registerId' => $object->getRegister(), + 'schemaId' => $object->getSchema(), + 'owner' => $object->getOwner(), + 'objectData' => $this->getSafeObjectData($object->getObject()), + ]); + } else if ($event instanceof ObjectLockedEvent) { + $object = $event->getObject(); + $data = array_merge($data, [ + 'eventType' => 'ObjectLocked', + 'objectId' => $object->getId(), + 'objectUuid' => $object->getUuid(), + 'registerId' => $object->getRegister(), + 'schemaId' => $object->getSchema(), + 'lockedBy' => $object->getLockedBy(), + 'lockedAt' => $object->getLockedAt()?->format('Y-m-d H:i:s'), + ]); + } else if ($event instanceof ObjectUnlockedEvent) { + $object = $event->getObject(); + $data = array_merge($data, [ + 'eventType' => 'ObjectUnlocked', + 'objectId' => $object->getId(), + 'objectUuid' => $object->getUuid(), + 'registerId' => $object->getRegister(), + 'schemaId' => $object->getSchema(), + ]); + } else if ($event instanceof ObjectRevertedEvent) { + $object = $event->getObject(); + $data = array_merge($data, [ + 'eventType' => 'ObjectReverted', + 'objectId' => $object->getId(), + 'objectUuid' => $object->getUuid(), + 'registerId' => $object->getRegister(), + 'schemaId' => $object->getSchema(), + 'revertedTo' => $event->getRevertedToVersion(), + ]); + } + + // Handle Register events + else if ($event instanceof RegisterCreatedEvent) { + $register = $event->getRegister(); + $data = array_merge($data, [ + 'eventType' => 'RegisterCreated', + 'registerId' => $register->getId(), + 'registerTitle' => $register->getTitle(), + 'registerSlug' => $register->getSlug(), + ]); + } else if ($event instanceof RegisterUpdatedEvent) { + $register = $event->getRegister(); + $data = array_merge($data, [ + 'eventType' => 'RegisterUpdated', + 'registerId' => $register->getId(), + 'registerTitle' => $register->getTitle(), + 'registerSlug' => $register->getSlug(), + ]); + } else if ($event instanceof RegisterDeletedEvent) { + $register = $event->getRegister(); + $data = array_merge($data, [ + 'eventType' => 'RegisterDeleted', + 'registerId' => $register->getId(), + 'registerTitle' => $register->getTitle(), + 'registerSlug' => $register->getSlug(), + ]); + } + + // Handle Schema events + else if ($event instanceof SchemaCreatedEvent) { + $schema = $event->getSchema(); + $data = array_merge($data, [ + 'eventType' => 'SchemaCreated', + 'schemaId' => $schema->getId(), + 'schemaTitle' => $schema->getTitle(), + 'schemaVersion' => $schema->getVersion(), + ]); + } else if ($event instanceof SchemaUpdatedEvent) { + $schema = $event->getSchema(); + $data = array_merge($data, [ + 'eventType' => 'SchemaUpdated', + 'schemaId' => $schema->getId(), + 'schemaTitle' => $schema->getTitle(), + 'schemaVersion' => $schema->getVersion(), + ]); + } else if ($event instanceof SchemaDeletedEvent) { + $schema = $event->getSchema(); + $data = array_merge($data, [ + 'eventType' => 'SchemaDeleted', + 'schemaId' => $schema->getId(), + 'schemaTitle' => $schema->getTitle(), + 'schemaVersion' => $schema->getVersion(), + ]); + } + + // Handle Organisation events + else if ($event instanceof OrganisationCreatedEvent) { + $organisation = $event->getOrganisation(); + $data = array_merge($data, [ + 'eventType' => 'OrganisationCreated', + 'organisationId' => $organisation->getId(), + 'organisationTitle' => $organisation->getTitle(), + ]); + } + + // Unknown event type + else { + $data['eventType'] = 'Unknown'; + $data['note'] = 'Event type not specifically handled by SoftwareCatalog debug listener'; + } + + return $data; + + }//end extractEventData() + + + /** + * Get safe object data for logging (truncated if too large) + * + * @param mixed $objectData The object data to make safe for logging + * + * @return mixed The safe object data + * + * @phpstan-return mixed + * @psalm-return mixed + */ + private function getSafeObjectData(mixed $objectData): mixed + { + // Convert to JSON string to check size + $jsonData = json_encode($objectData); + + // If the data is too large (>2KB), truncate it + if (strlen($jsonData) > 2048) { + return [ + '_truncated' => true, + '_originalSize' => strlen($jsonData), + '_preview' => substr($jsonData, 0, 500) . '...', + '_note' => 'Object data truncated for logging - too large to display fully' + ]; + } + + return $objectData; + + }//end getSafeObjectData() + + +}//end class diff --git a/lib/EventListener/SoftwareCatalogEventListener.php b/lib/EventListener/SoftwareCatalogEventListener.php index be1d0ede..d88ac73c 100644 --- a/lib/EventListener/SoftwareCatalogEventListener.php +++ b/lib/EventListener/SoftwareCatalogEventListener.php @@ -20,6 +20,7 @@ use OCA\SoftwareCatalog\Service\ContactpersoonService; use OCA\SoftwareCatalog\Service\SettingsService; +use OCA\SoftwareCatalog\Service\GebruikSyncService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCA\OpenRegister\Event\ObjectCreatedEvent; @@ -66,21 +67,45 @@ public function __construct() { */ public function handle(Event $event): void { - // All processing is now handled by the cron-based OrganizationSyncService - // This prevents race conditions, infinite loops, and ensures consistent processing - try { $logger = \OC::$server->get(LoggerInterface::class); - $logger->debug('SoftwareCatalog: Event processing disabled - using cron-based sync', [ + $contactpersoonService = \OC::$server->get(ContactpersoonService::class); + $settingsService = \OC::$server->get(SettingsService::class); + + $logger->debug('SoftwareCatalog: Processing event', [ 'eventType' => get_class($event), - 'message' => 'All processing is handled by OrganizationSyncService cron job to avoid race conditions' + 'timestamp' => date('Y-m-d H:i:s') ]); + + if ($event instanceof ObjectCreatedEvent) { + $this->handleObjectCreated($event, $contactpersoonService, $settingsService, $logger); + } elseif ($event instanceof ObjectUpdatedEvent) { + $this->handleObjectUpdated($event, $contactpersoonService, $settingsService, $logger); + } elseif ($event instanceof ObjectDeletedEvent) { + $this->handleObjectDeleted($event, $contactpersoonService, $settingsService, $logger); + } elseif ($event instanceof ObjectLockedEvent || $event instanceof ObjectUnlockedEvent || $event instanceof ObjectRevertedEvent) { + $logger->debug('SoftwareCatalog: Ignoring object lifecycle event', [ + 'eventType' => get_class($event) + ]); + } else { + $logger->debug('SoftwareCatalog: Unknown event type ignored', [ + 'eventType' => get_class($event) + ]); + } } catch (\Exception $e) { - // Silently fail - logging is not critical + try { + $logger = \OC::$server->get(LoggerInterface::class); + $logger->error('SoftwareCatalog: Error in event handler', [ + 'eventType' => get_class($event), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); + } catch (\Exception $logException) { + // Silently fail if logging fails - better than breaking the event system + } } - - // Early return - no processing - return; } @@ -124,6 +149,7 @@ private function handleObjectCreated(ObjectCreatedEvent $event, ContactpersoonSe $organisatieSchemaId = $settingsService->getSchemaIdForObjectType('organisatie'); $contactpersoonSchemaId = $settingsService->getSchemaIdForObjectType('contactpersoon'); $contactgegevensSchemaId = $settingsService->getSchemaIdForObjectType('contactgegevens'); + $gebruikSchemaId = $settingsService->getSchemaIdForObjectType('gebruik'); $logger->debug( 'SoftwareCatalog: Configuration lookup results', @@ -131,13 +157,46 @@ private function handleObjectCreated(ObjectCreatedEvent $event, ContactpersoonSe 'organisatieSchemaId' => $organisatieSchemaId, 'contactpersoonSchemaId' => $contactpersoonSchemaId, 'contactgegevensSchemaId' => $contactgegevensSchemaId, + 'gebruikSchemaId' => $gebruikSchemaId, 'objectSchemaId' => $objectSchemaIdInt ] ); - // Organization processing is now handled by cron job - skip organization events + // Check if this is an organization object if ($organisatieSchemaId && $objectSchemaIdInt === (int) $organisatieSchemaId) { - $logger->debug('SoftwareCatalog: Skipping organization creation - handled by cron job', ['objectId' => $objectId]); + $objectData = $object->getObject(); + $status = strtolower($objectData['status'] ?? ''); + + // Only process active organizations + if (in_array($status, ['actief', 'active'])) { + $logger->info('SoftwareCatalog: Processing active organization creation', [ + 'objectId' => $objectId, + 'status' => $status + ]); + + try { + // Process organization with OrganizationSyncService + $organizationSyncService = \OC::$server->get('OCA\SoftwareCatalog\Service\OrganizationSyncService'); + $result = $organizationSyncService->processSpecificOrganization($object); + + $logger->info('SoftwareCatalog: Successfully processed organization creation', [ + 'objectId' => $objectId, + 'processResult' => $result + ]); + } catch (\Exception $e) { + $logger->error('SoftwareCatalog: Failed to process organization creation', [ + 'objectId' => $objectId, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } + } else { + $logger->debug('SoftwareCatalog: Skipping non-active organization creation', [ + 'objectId' => $objectId, + 'status' => $status + ]); + } return; } @@ -155,6 +214,30 @@ private function handleObjectCreated(ObjectCreatedEvent $event, ContactpersoonSe return; } + // Check if this is a gebruik object + if ($gebruikSchemaId && $objectSchemaIdInt === (int) $gebruikSchemaId) { + $logger->info('SoftwareCatalog: Processing gebruik creation', ['objectId' => $objectId]); + + try { + // Process gebruik object with GebruikSyncService + $gebruikSyncService = \OC::$server->get(GebruikSyncService::class); + $result = $gebruikSyncService->processSpecificGebruik($object); + + $logger->info('SoftwareCatalog: Successfully processed gebruik creation', [ + 'objectId' => $objectId, + 'processResult' => $result + ]); + } catch (\Exception $e) { + $logger->error('SoftwareCatalog: Failed to process gebruik creation', [ + 'objectId' => $objectId, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } + return; + } + // Log unhandled object types $logger->debug( 'SoftwareCatalog: Object creation not handled - not a supported object type', @@ -165,7 +248,8 @@ private function handleObjectCreated(ObjectCreatedEvent $event, ContactpersoonSe 'supportedSchemas' => [ 'organisatie' => $organisatieSchemaId, 'contactpersoon' => $contactpersoonSchemaId, - 'contactgegevens' => $contactgegevensSchemaId + 'contactgegevens' => $contactgegevensSchemaId, + 'gebruik' => $gebruikSchemaId ] ] ); @@ -210,19 +294,46 @@ private function handleObjectUpdated(ObjectUpdatedEvent $event, ContactpersoonSe ] ); - // Organization updates are now handled by cron job - skip organization events + // Check if this is an organization update $organisatieSchemaId = $settingsService->getSchemaIdForObjectType('organisatie'); $organisatieSchemaIdInt = (int) $organisatieSchemaId; if ($organisatieSchemaId && $objectSchemaIdInt === $organisatieSchemaIdInt) { - $logger->info( - 'SoftwareCatalog: Skipping organisatie update - handled by cron job', - [ + $objectData = $object->getObject(); + $status = strtolower($objectData['status'] ?? ''); + + // Only process active organizations + if (in_array($status, ['actief', 'active'])) { + $logger->info('SoftwareCatalog: Processing active organization update', [ 'objectId' => $objectId, - 'schemaId' => $objectSchemaId, - 'configuredSchemaId' => $organisatieSchemaId - ] - ); + 'status' => $status, + 'schemaId' => $objectSchemaId + ]); + + try { + // Process organization with OrganizationSyncService + $organizationSyncService = \OC::$server->get('OCA\SoftwareCatalog\Service\OrganizationSyncService'); + $result = $organizationSyncService->processSpecificOrganization($object); + + $logger->info('SoftwareCatalog: Successfully processed organization update', [ + 'objectId' => $objectId, + 'processResult' => $result + ]); + } catch (\Exception $e) { + $logger->error('SoftwareCatalog: Failed to process organization update', [ + 'objectId' => $objectId, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } + } else { + $logger->debug('SoftwareCatalog: Skipping non-active organization update', [ + 'objectId' => $objectId, + 'status' => $status, + 'schemaId' => $objectSchemaId + ]); + } return; } @@ -305,9 +416,51 @@ private function handleObjectUpdated(ObjectUpdatedEvent $event, ContactpersoonSe return; } + // Handle gebruik updates + $gebruikSchemaId = $settingsService->getSchemaIdForObjectType('gebruik'); + $gebruikSchemaIdInt = (int) $gebruikSchemaId; + + if ($gebruikSchemaId && $objectSchemaIdInt === $gebruikSchemaIdInt) { + $logger->info( + 'SoftwareCatalog: Matched gebruik schema - processing update', + [ + 'objectId' => $objectId, + 'schemaId' => $objectSchemaId, + 'configuredSchemaId' => $gebruikSchemaId + ] + ); + + try { + // Process gebruik object with GebruikSyncService + $gebruikSyncService = \OC::$server->get(GebruikSyncService::class); + $result = $gebruikSyncService->processSpecificGebruik($object); + + $logger->info( + 'SoftwareCatalog: Successfully processed gebruik update', + [ + 'objectId' => $objectId, + 'processResult' => $result, + 'timestamp' => date('Y-m-d H:i:s') + ] + ); + } catch (\Exception $e) { + $logger->error( + 'SoftwareCatalog: Failed to process gebruik update', + [ + 'objectId' => $objectId, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ] + ); + } + return; + } + // Log if we don't handle this schema type $logger->debug( - 'SoftwareCatalog: Object update not handled - focusing only on organisatie and contactpersonen', + 'SoftwareCatalog: Object update not handled - focusing only on organisatie, contactpersonen, and gebruik', [ 'objectId' => $objectId, 'schemaId' => $objectSchemaId, @@ -317,7 +470,8 @@ private function handleObjectUpdated(ObjectUpdatedEvent $event, ContactpersoonSe 'handledSchemas' => [ 'organisatie' => $organisatieSchemaId, 'contactpersoon' => $contactpersoonSchemaId, - 'contactgegevens' => $contactgegevensSchemaId + 'contactgegevens' => $contactgegevensSchemaId, + 'gebruik' => $gebruikSchemaId ] ] ); @@ -354,13 +508,35 @@ private function handleObjectDeleted(ObjectDeletedEvent $event, ContactpersoonSe ] ); - // Organization deletion is now handled by cron job - skip organization events + // Check if this is an organization deletion $organisatieSchemaId = $settingsService->getSchemaIdForObjectType('organisatie'); $organisatieSchemaIdInt = (int) $organisatieSchemaId; $objectSchemaIdInt = (int) $objectSchemaId; if ($organisatieSchemaId && $objectSchemaIdInt === $organisatieSchemaIdInt) { - $logger->info('SoftwareCatalog: Skipping organization deletion - handled by cron job', ['objectId' => $objectId]); + $logger->info('SoftwareCatalog: Processing organization deletion', ['objectId' => $objectId]); + + try { + // For deletions, we may need to handle cleanup regardless of status + // The OrganizationSyncService can determine what cleanup is needed + $organizationSyncService = \OC::$server->get('OCA\SoftwareCatalog\Service\OrganizationSyncService'); + + // Note: processSpecificOrganization may handle cleanup for deleted organizations + // The service can check if the organization exists and handle accordingly + $result = $organizationSyncService->processSpecificOrganization($object); + + $logger->info('SoftwareCatalog: Successfully processed organization deletion', [ + 'objectId' => $objectId, + 'processResult' => $result + ]); + } catch (\Exception $e) { + $logger->error('SoftwareCatalog: Failed to process organization deletion', [ + 'objectId' => $objectId, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } return; } @@ -442,9 +618,39 @@ private function handleObjectDeleted(ObjectDeletedEvent $event, ContactpersoonSe return; } + // Handle gebruik deletion + $gebruikSchemaId = $settingsService->getSchemaIdForObjectType('gebruik'); + $gebruikSchemaIdInt = (int) $gebruikSchemaId; + + if ($gebruikSchemaId && $objectSchemaIdInt === $gebruikSchemaIdInt) { + $objectData = $object->getObject(); + + $logger->info( + 'SoftwareCatalog: Matched gebruik schema - processing deletion', + [ + 'objectId' => $objectId, + 'schemaId' => $objectSchemaId, + 'configuredSchemaId' => $gebruikSchemaId, + 'afnemer' => $objectData['afnemer']['naam'] ?? 'Unknown', + 'product' => $objectData['product']['naam'] ?? 'Unknown' + ] + ); + + // For deletions, we mainly log the event since the object is being removed + // No specific cleanup needed for gebruik objects currently + $logger->info( + 'SoftwareCatalog: Gebruik object deleted - no specific cleanup required', + [ + 'objectId' => $objectId, + 'timestamp' => date('Y-m-d H:i:s') + ] + ); + return; + } + // Log if we don't handle this schema type $logger->debug( - 'SoftwareCatalog: Object deletion not handled - focusing only on organisatie and contactpersonen', + 'SoftwareCatalog: Object deletion not handled - focusing only on organisatie, contactpersonen, and gebruik', [ 'objectId' => $objectId, 'schemaId' => $objectSchemaId, @@ -452,7 +658,8 @@ private function handleObjectDeleted(ObjectDeletedEvent $event, ContactpersoonSe 'handledSchemas' => [ 'organisatie' => $organisatieSchemaId, 'contactpersoon' => $contactpersoonSchemaId, - 'contactgegevens' => $contactgegevensSchemaId + 'contactgegevens' => $contactgegevensSchemaId, + 'gebruik' => $gebruikSchemaId ] ] ); diff --git a/lib/EventListener/TestEventListener.php b/lib/EventListener/TestEventListener.php new file mode 100644 index 00000000..0996f61b --- /dev/null +++ b/lib/EventListener/TestEventListener.php @@ -0,0 +1,109 @@ + + * @copyright 2024 Conduction B.V. + * @license AGPL-3.0-or-later https://www.gnu.org/licenses/agpl-3.0.html + * @version 1.0.0 + * @link https://github.com/ConductionNL/OpenConnector + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\EventListener; + +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\User\Events\UserLoggedInEvent; +use Psr\Log\LoggerInterface; + +/** + * Test event listener for verifying event listener functionality. + * + * This listener handles user login events to test that our event system + * is working correctly. It logs when users log in and can be easily + * triggered for testing purposes. + * + * @category EventListener + * @package OCA\SoftwareCatalog\EventListener + * @author Conduction b.v. + * @license AGPL-3.0-or-later https://www.gnu.org/licenses/agpl-3.0.html + * @version 1.0.0 + * @link https://github.com/ConductionNL/OpenConnector + */ +class TestEventListener implements IEventListener +{ + /** + * Constructor for TestEventListener + * + * @param LoggerInterface $logger The logger service for logging events + */ + public function __construct( + private readonly LoggerInterface $logger + ) { + } + + /** + * Handles events related to user login for testing purposes + * + * This method processes UserLoggedInEvent events and logs detailed + * information to verify that the event listener system is working + * correctly. + * + * @param Event $event The event to handle + * + * @return void + */ + public function handle(Event $event): void + { + // Log that we received ANY event first + $this->logger->info('SoftwareCatalog TestEventListener: Event received!', [ + 'eventClass' => get_class($event), + 'timestamp' => date('Y-m-d H:i:s'), + 'microtime' => microtime(true) + ]); + + + + // Handle UserLoggedInEvent specifically + if ($event instanceof UserLoggedInEvent) { + $user = $event->getUser(); + + $this->logger->info('SoftwareCatalog TestEventListener: User logged in successfully!', [ + 'userId' => $user->getUID(), + 'userDisplayName' => $user->getDisplayName(), + 'userEmail' => $user->getEMailAddress(), + 'timestamp' => date('Y-m-d H:i:s'), + 'eventType' => 'UserLoggedInEvent' + ]); + + + + // Test that we can access Nextcloud services + try { + $this->logger->debug('SoftwareCatalog TestEventListener: Event listener is working correctly!', [ + 'message' => 'This confirms that event listeners are properly registered and triggered', + 'userId' => $user->getUID(), + 'eventClass' => get_class($event) + ]); + } catch (\Exception $e) { + $this->logger->error('SoftwareCatalog TestEventListener: Error in event processing', [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + } else { + // Log other events we might receive + $this->logger->debug('SoftwareCatalog TestEventListener: Received unhandled event', [ + 'eventClass' => get_class($event), + 'timestamp' => date('Y-m-d H:i:s') + ]); + } + } +} diff --git a/lib/Service/AangebodenGebruikService.php b/lib/Service/AangebodenGebruikService.php new file mode 100644 index 00000000..ae2735c2 --- /dev/null +++ b/lib/Service/AangebodenGebruikService.php @@ -0,0 +1,487 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version 1.0.0 + * @link https://github.com/ConductionNL/SoftwareCatalog + */ + +namespace OCA\SoftwareCatalog\Service; + +use OCA\OpenRegister\Service\ObjectService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Exception; + +/** + * Service for managing offered usage (aangeboden gebruik) operations + * + * This service provides operations for querying gebruiks objects where the active + * organization is involved either as the afnemer (consumer) or in the deelnemers + * (participants) array, and for updating the @self property of gebruiks objects. + * + * @category Service + * @package OCA\SoftwareCatalog\Service + * @author Conduction b.v. + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version 1.0.0 + * @link https://github.com/ConductionNL/SoftwareCatalog + */ +class AangebodenGebruikService +{ + /** + * Constructor for AangebodenGebruikService + * + * @param IAppConfig $config Nextcloud app configuration service + * @param IAppManager $appManager App manager service for checking available apps + * @param ContainerInterface $container PSR-11 container interface for dependency injection + * @param LoggerInterface $logger Logger service for debugging and error reporting + * @param SettingsService $settingsService Settings service for retrieving configuration + * @param IUserSession $userSession User session service for current user context + */ + public function __construct( + private readonly IAppConfig $config, + private readonly IAppManager $appManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + private readonly SettingsService $settingsService, + private readonly IUserSession $userSession + ) { + } + + /** + * Get all gebruiks objects where the active organization is the afnemer (consumer) + * + * This method retrieves all gebruiks objects where the active organization + * appears as the afnemer using standard RBAC filtering. + * + * @param array $options Additional query options (limit, offset, filters, etc.) + * @return array Array with success status, gebruiks data, and metadata + * @throws Exception When OpenRegister service is not available + */ + public function getGebruiksWhereAfnemer(array $options = []): array + { + $this->logger->info('Getting gebruiks objects where active org is afnemer', [ + 'options' => $options + ]); + + try { + // Get ObjectService from OpenRegister + $objectService = $this->getObjectService(); + + // Get current organization + $currentOrg = $this->getCurrentOrganisation(); + if (!$currentOrg) { + $this->logger->warning('No current organization available for afnemer filtering'); + return [ + 'success' => true, + 'gebruiks' => [], + 'count' => 0, + 'message' => 'No current organization available' + ]; + } + + // Get configuration for gebruiks register/schema + $gebruiksConfig = $this->getGebruiksConfiguration(); + + $allGebruiks = []; + + // Search each configured schema for gebruiks where org is afnemer + foreach ($gebruiksConfig['schemas'] as $schemaId) { + if (!$schemaId) continue; + + try { + // Build query for afnemer filtering with RBAC enabled + $query = [ + '@self' => [ + 'register' => $gebruiksConfig['register_id'], + 'schema' => $schemaId, + 'organisation' => $currentOrg // Standard RBAC filtering + ] + ]; + + // Add additional filters from options + $query = $this->addQueryFilters($query, $options); + + // Execute search with RBAC enabled (default behavior) + $gebruikItems = $objectService->searchObjects($query); + + // Process and add to results + foreach ($gebruikItems as $gebruik) { + $gebruik['_filter_type'] = 'afnemer'; + $gebruik['_schema_id'] = $schemaId; + $allGebruiks[] = $gebruik; + } + + $this->logger->debug('Retrieved afnemer gebruiks from schema', [ + 'schema_id' => $schemaId, + 'count' => count($gebruikItems), + 'organisation' => $currentOrg + ]); + + } catch (Exception $e) { + $this->logger->warning('Failed to get afnemer gebruiks from schema', [ + 'schema_id' => $schemaId, + 'error' => $e->getMessage() + ]); + } + } + + return [ + 'success' => true, + 'gebruiks' => $allGebruiks, + 'count' => count($allGebruiks), + 'filter_type' => 'afnemer', + 'organisation' => $currentOrg + ]; + + } catch (Exception $e) { + $this->logger->error('Failed to get afnemer gebruiks', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return [ + 'success' => false, + 'error' => 'Failed to retrieve gebruiks: ' . $e->getMessage(), + 'gebruiks' => [], + 'count' => 0 + ]; + } + } + + /** + * Get all gebruiks objects where the active organization is in deelnemers (participants) + * + * This method retrieves all gebruiks objects where the active organization + * appears in the deelnemers array, using RBAC-disabled search. + * + * @param array $options Additional query options (limit, offset, filters, etc.) + * @return array Array with success status, gebruiks data, and metadata + * @throws Exception When OpenRegister service is not available + */ + public function getGebruiksWhereDeelnemers(array $options = []): array + { + $this->logger->info('Getting gebruiks objects where active org is in deelnemers', [ + 'options' => $options + ]); + + try { + // Get ObjectService from OpenRegister + $objectService = $this->getObjectService(); + + // Get current organization + $currentOrg = $this->getCurrentOrganisation(); + if (!$currentOrg) { + $this->logger->warning('No current organization available for deelnemers filtering'); + return [ + 'success' => true, + 'gebruiks' => [], + 'count' => 0, + 'message' => 'No current organization available' + ]; + } + + // Get configuration for gebruiks register/schema + $gebruiksConfig = $this->getGebruiksConfiguration(); + + $allGebruiks = []; + + // Search each configured schema for gebruiks where org is in deelnemers + foreach ($gebruiksConfig['schemas'] as $schemaId) { + if (!$schemaId) continue; + + try { + // Build query for deelnemers filtering + $query = [ + '@self' => [ + 'register' => $gebruiksConfig['register_id'], + 'schema' => $schemaId + ], + 'deelnemers' => $currentOrg // Search where current org is in deelnemers + ]; + + // Add additional filters from options + $query = $this->addQueryFilters($query, $options); + + // Execute search with RBAC disabled to find deelnemers + $gebruikItems = $objectService->searchObjects($query, rbac: false); + + // Process and add to results + foreach ($gebruikItems as $gebruik) { + $gebruik['_filter_type'] = 'deelnemers'; + $gebruik['_schema_id'] = $schemaId; + $allGebruiks[] = $gebruik; + } + + $this->logger->debug('Retrieved deelnemers gebruiks from schema', [ + 'schema_id' => $schemaId, + 'count' => count($gebruikItems), + 'organisation_in_deelnemers' => $currentOrg + ]); + + } catch (Exception $e) { + $this->logger->warning('Failed to get deelnemers gebruiks from schema', [ + 'schema_id' => $schemaId, + 'error' => $e->getMessage() + ]); + } + } + + return [ + 'success' => true, + 'gebruiks' => $allGebruiks, + 'count' => count($allGebruiks), + 'filter_type' => 'deelnemers', + 'organisation' => $currentOrg + ]; + + } catch (Exception $e) { + $this->logger->error('Failed to get deelnemers gebruiks', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return [ + 'success' => false, + 'error' => 'Failed to retrieve gebruiks: ' . $e->getMessage(), + 'gebruiks' => [], + 'count' => 0 + ]; + } + } + + /** + * Set the @self property of a gebruik to the active organization + * + * This method updates the @self.organisation property of a specific gebruik + * object, but only if the active organization is the afnemer for that gebruik. + * + * @param string $gebruikId The UUID of the gebruik object to update + * @param array $options Additional update options + * @return array Result with success status and updated object data + * @throws Exception When OpenRegister service is not available or operation fails + */ + public function setGebruikSelfToActiveOrg(string $gebruikId, array $options = []): array + { + $this->logger->info('Setting gebruik @self property to active organisation', [ + 'gebruik_id' => $gebruikId, + 'options' => $options + ]); + + try { + // Validate input + if (empty($gebruikId)) { + return [ + 'success' => false, + 'error' => 'Gebruik ID is required', + 'gebruik' => null + ]; + } + + // Get ObjectService from OpenRegister + $objectService = $this->getObjectService(); + + // Get current organization + $currentOrg = $this->getCurrentOrganisation(); + if (!$currentOrg) { + return [ + 'success' => false, + 'error' => 'No current organization available', + 'gebruik' => null + ]; + } + + // Get the existing gebruik object + $existingGebruik = $objectService->getObject($gebruikId); + if (!$existingGebruik) { + return [ + 'success' => false, + 'error' => 'Gebruik object not found', + 'gebruik' => null + ]; + } + + // Verify that the active organization is the afnemer + $gebruikData = $existingGebruik->getObject(); + $afnemerInfo = $gebruikData['afnemer'] ?? null; + + // Check various ways the afnemer might be stored (UUID, object, or string) + $afnemerId = null; + if (is_array($afnemerInfo) && isset($afnemerInfo['id'])) { + $afnemerId = $afnemerInfo['id']; + } elseif (is_string($afnemerInfo)) { + $afnemerId = $afnemerInfo; + } + + if (!$afnemerId || $afnemerId !== $currentOrg) { + return [ + 'success' => false, + 'error' => 'Operation not allowed: active organization is not the afnemer', + 'gebruik' => null, + 'debug' => [ + 'afnemer_in_object' => $afnemerInfo, + 'resolved_afnemer_id' => $afnemerId, + 'current_org' => $currentOrg + ] + ]; + } + + // Update the @self.organisation property + $selfData = $gebruikData['@self'] ?? []; + $selfData['organisation'] = $currentOrg; + $gebruikData['@self'] = $selfData; + + // Save the updated object + $existingGebruik->setObject($gebruikData); + $updatedGebruik = $objectService->saveObject($existingGebruik); + + $this->logger->info('Successfully updated gebruik @self property', [ + 'gebruik_id' => $gebruikId, + 'organisation' => $currentOrg, + 'afnemer_verified' => $afnemerId + ]); + + return [ + 'success' => true, + 'message' => 'Gebruik @self property updated successfully', + 'gebruik' => $updatedGebruik->getObject(), + 'updated_fields' => ['@self.organisation'] + ]; + + } catch (Exception $e) { + $this->logger->error('Failed to update gebruik @self property', [ + 'gebruik_id' => $gebruikId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return [ + 'success' => false, + 'error' => 'Failed to update gebruik: ' . $e->getMessage(), + 'gebruik' => null + ]; + } + } + + /** + * Get current active organisation for filtering + * + * @return string|null Current organisation identifier or null if no user session + */ + private function getCurrentOrganisation(): ?string + { + $user = $this->userSession->getUser(); + if (!$user) { + return null; + } + + // Get user's organization from configuration or session + // This follows the same pattern as ViewService + $userOrg = $this->config->getUserValue( + $user->getUID(), + 'softwarecatalog', + 'organisation', + null + ); + + return $userOrg; + } + + /** + * Get ObjectService from OpenRegister app + * + * @return ObjectService The OpenRegister object service + * @throws Exception When OpenRegister service is not available + */ + private function getObjectService(): ObjectService + { + if (!in_array('openregister', $this->appManager->getInstalledApps())) { + throw new Exception('OpenRegister app is not installed'); + } + + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (Exception $e) { + throw new Exception('Failed to get OpenRegister service: ' . $e->getMessage()); + } + } + + /** + * Get configuration for gebruiks objects (register ID and schema IDs) + * + * @return array Configuration with register_id and schemas array + */ + private function getGebruiksConfiguration(): array + { + // Get AMEF configuration which includes gebruiks schemas + $amefConfig = $this->settingsService->getAmefConfig(); + + return [ + 'register_id' => $amefConfig['register_id'] ?? null, + 'schemas' => $amefConfig['gebruik_schemas'] ?? [] + ]; + } + + /** + * Add query filters from options to the base query + * + * This method processes additional filter options and adds them to the query. + * Supported filters: limit, offset, status, product, etc. + * + * @param array $baseQuery The base query to extend + * @param array $options Filter options to apply + * @return array Extended query with additional filters + */ + private function addQueryFilters(array $baseQuery, array $options): array + { + // Add limit if specified + if (isset($options['limit']) && is_numeric($options['limit'])) { + $baseQuery['@limit'] = (int)$options['limit']; + } + + // Add offset if specified + if (isset($options['offset']) && is_numeric($options['offset'])) { + $baseQuery['@offset'] = (int)$options['offset']; + } + + // Add status filter if specified + if (isset($options['status']) && !empty($options['status'])) { + $baseQuery['status'] = $options['status']; + } + + // Add product filter if specified + if (isset($options['product']) && !empty($options['product'])) { + $baseQuery['product'] = $options['product']; + } + + // Add date filters if specified + if (isset($options['startDate']) && !empty($options['startDate'])) { + $baseQuery['startDate'] = $options['startDate']; + } + + if (isset($options['endDate']) && !empty($options['endDate'])) { + $baseQuery['endDate'] = $options['endDate']; + } + + return $baseQuery; + } +} + + diff --git a/lib/Service/ArchiMateExportService.php b/lib/Service/ArchiMateExportService.php index 461a1dc1..6cb8870b 100644 --- a/lib/Service/ArchiMateExportService.php +++ b/lib/Service/ArchiMateExportService.php @@ -46,6 +46,11 @@ public function arrayToXml(array $data, \SimpleXMLElement $xml): \SimpleXMLEleme // Skip legacy _attributes bag, handle individual underscored keys as attributes $attrKey = substr($key, 1); + // Skip malformed attribute keys that would create invalid XML (e.g., __propertyDefinitionRef -> :propertyDefinitionRef) + if (str_starts_with($attrKey, '__') || $attrKey === '') { + continue; + } + // Fix double underscores to colons (e.g., xml__lang -> xml:lang) $attrKey = str_replace('__', ':', $attrKey); @@ -110,6 +115,21 @@ public function arrayToXml(array $data, \SimpleXMLElement $xml): \SimpleXMLEleme continue; } + // Skip property-like fields that should be handled by specialized property methods + // These fields often appear as direct data but should only be in structure + $propertyLikeFields = [ + 'beschikbaarheid', 'integriteit', 'vertrouwelijkheid', 'gemmaType', + 'objectId', 'bivScoreBbn', 'belangrijksteReden' + ]; + if (in_array($key, $propertyLikeFields, true)) { + continue; // Skip these - they should only appear in proper structure + } + + // Special handling for elementProperties and other nested structures - filter out problematic fields + if (($key === 'elementProperties' || $key === 'properties' || $key === 'viewNodes') && is_array($value)) { + $value = $this->filterProblematicFields($value, $propertyLikeFields); + } + // Ensure key is always a string for XML tag names $tagName = (string) $key; @@ -155,6 +175,53 @@ private function splitNamespacedKey(string $key): array return [null, $key]; } + /** + * Recursively filter out problematic fields from nested data structures + * + * @param array $data The data structure to filter + * @param array $fieldsToRemove List of field names to remove + * @return array Filtered data structure + */ + private function filterProblematicFields(array $data, array $fieldsToRemove): array + { + $filtered = []; + + foreach ($data as $key => $value) { + $shouldSkip = false; + + // Skip exact matches + if (in_array($key, $fieldsToRemove, true)) { + $shouldSkip = true; + } + + // Skip fields that start with problematic patterns (e.g., "beschikbaarheid(belangrijksteReden)") + foreach ($fieldsToRemove as $fieldPattern) { + if (str_starts_with($key, $fieldPattern)) { + $shouldSkip = true; + break; + } + } + + // Skip fields with invalid XML tag name characters (parentheses, etc.) + if (preg_match('/[(),<>\/\\\]/', $key)) { + $shouldSkip = true; + } + + if ($shouldSkip) { + continue; + } + + // Recursively filter nested arrays + if (is_array($value)) { + $filtered[$key] = $this->filterProblematicFields($value, $fieldsToRemove); + } else { + $filtered[$key] = $value; + } + } + + return $filtered; + } + private function getNamespaceUri(\SimpleXMLElement $xml, string $prefix): string { $namespaces = $xml->getDocNamespaces(true) ?: []; @@ -244,12 +311,14 @@ public function addRelationshipsToXml(\SimpleXMLElement $xml, array $relationshi */ public function addViewsToXml(\SimpleXMLElement $xml, array $views): void { - echo "ADDVIEWSTOXML CALLED WITH " . count($views) . " VIEWS\n"; - var_dump(array_keys($views)); + $this->logger->debug('Adding views to XML', [ + 'view_count' => count($views), + 'view_keys' => array_keys($views) + ]); if (empty($views)) { - echo "NO VIEWS TO PROCESS\n"; - die(); + $this->logger->warning('No views to process'); + return; } $folder = $xml->addChild('folder'); @@ -281,25 +350,25 @@ private function addViewToFolder(\SimpleXMLElement $folder, array $view): void $viewData = $this->extractViewData($view); if (!$viewData) { - echo "NO VALID VIEW DATA FOUND\n"; - var_dump(['view_keys' => array_keys($view), 'view' => $view]); - die(); + $this->logger->warning('No valid view data found', [ + 'view_keys' => array_keys($view), + 'view_structure' => $view + ]); + return; } // DEBUG: Check if this is our target view with nodes if (isset($viewData['_identifier']) && $viewData['_identifier'] === 'id-1c197dc3-71e5-40dc-8f5d-a96e983b41af') { - echo "FOUND TARGET VIEW WITH ID: id-1c197dc3-71e5-40dc-8f5d-a96e983b41af\n"; - echo "Raw view input:\n"; - var_dump($view); - echo "\nExtracted viewData:\n"; - var_dump($viewData); - echo "\nNode data check:\n"; - var_dump([ - 'has_node' => isset($viewData['node']), - 'node_count' => is_array($viewData['node'] ?? null) ? count($viewData['node']) : 0, - 'node_sample' => isset($viewData['node'][0]) ? $viewData['node'][0] : 'NO FIRST NODE' + $this->logger->debug('Found target view with specific ID', [ + 'identifier' => $viewData['_identifier'], + 'raw_view' => $view, + 'extracted_view_data' => $viewData, + 'node_analysis' => [ + 'has_node' => isset($viewData['node']), + 'node_count' => is_array($viewData['node'] ?? null) ? count($viewData['node']) : 0, + 'node_sample' => isset($viewData['node'][0]) ? $viewData['node'][0] : 'NO FIRST NODE' + ] ]); - die(); } $this->logger->debug('Processing view with custom logic', [ @@ -563,6 +632,7 @@ private function generateXmlDirectly(array $objects, array $schemaIdMap): string // Create base XML structure with model metadata $modelMetadata = $this->extractModelMetadata($objects); + $propertyDefinitionMap = $modelMetadata['propertyDefinitionMap'] ?? []; $xml = $this->createCleanArchiMateXml($modelMetadata); // Add model name and properties if available @@ -596,9 +666,25 @@ private function generateXmlDirectly(array $objects, array $schemaIdMap): string $validSections = ['elements', 'relationships', 'views', 'organizations', 'property_definitions']; $sectionCounts = []; + // Map singular section names to plural for XML generation + $sectionMapping = [ + 'element' => 'elements', + 'relationship' => 'relationships', + 'view' => 'views', + 'organization' => 'organizations', + 'property_definition' => 'property_definitions' + ]; + foreach ($validSections as $sectionName) { - if (isset($objectsBySection[$sectionName]) && !empty($objectsBySection[$sectionName])) { - $sectionObjects = $objectsBySection[$sectionName]; + // Check both singular and plural section names + $sectionObjects = []; + foreach ($objectsBySection as $dbSection => $objects) { + if (isset($sectionMapping[$dbSection]) && $sectionMapping[$dbSection] === $sectionName) { + $sectionObjects = array_merge($sectionObjects, $objects); + } + } + + if (!empty($sectionObjects)) { $sectionCounts[$sectionName] = count($sectionObjects); // Create section folder @@ -606,7 +692,7 @@ private function generateXmlDirectly(array $objects, array $schemaIdMap): string // Add all objects in this section foreach ($sectionObjects as $object) { - $this->addObjectDirectlyToXml($sectionFolder, $object, $sectionName); + $this->addObjectDirectlyToXmlWithProperties($sectionFolder, $object, $sectionName, $propertyDefinitionMap); } $this->logger->debug("Generated XML section: {$sectionName}", [ @@ -653,32 +739,25 @@ private function createSectionFolder(\SimpleXMLElement $xml, string $sectionName } /** - * Add object directly to XML without intermediate conversions - * - * Now works with the flattened object structure where XML data is at root level + * Add object directly to XML with properties from root fields */ - private function addObjectDirectlyToXml(\SimpleXMLElement $folder, array $object, string $sectionName): void + private function addObjectDirectlyToXmlWithProperties(\SimpleXMLElement $folder, array $object, string $sectionName, array $propertyDefinitionMap): void { $tagName = match($sectionName) { 'organizations' => 'item', 'property_definitions' => 'propertyDefinition', 'views' => 'view', 'relationships' => 'relationship', + 'elements' => 'element', default => 'element' }; - $objectNode = $folder->addChild($tagName); - - // Create clean XML data by filtering out metadata and duplicate fields - $xmlData = $this->cleanObjectDataForXml($object); - + $xmlData = $this->cleanObjectDataForXml($object, $propertyDefinitionMap); if (is_array($xmlData) && !empty($xmlData)) { - // Use specialized handling for views to ensure nodes are processed correctly if ($sectionName === 'views') { $this->addViewDataToXmlNode($objectNode, $xmlData); } else { - // Pass section information to help with attribute handling - $this->addCleanDataToXmlNode($objectNode, $xmlData, $sectionName); + $this->addCleanDataToXmlNode($objectNode, $xmlData, $sectionName, $propertyDefinitionMap); } } } @@ -855,7 +934,7 @@ private function formatXmlOutput(string $xmlString): string /** * Clean object data for XML export - remove metadata and duplicate attributes */ - private function cleanObjectDataForXml(array $object): array + private function cleanObjectDataForXml(array $object, array $propertyDefinitionMap = []): array { // Remove our metadata fields $cleanData = $object; @@ -885,37 +964,34 @@ private function cleanObjectDataForXml(array $object): array unset($cleanData[$field]); } + // Remove flattened properties that will be reconstructed separately + if (!empty($propertyDefinitionMap)) { + foreach ($propertyDefinitionMap as $propRef => $propName) { + unset($cleanData[$propName]); + } + } + return $cleanData; } /** * Add clean data to XML node with proper ArchiMate structure */ - private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?string $sectionName = null): void + private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?string $sectionName = null, array $propertyDefinitionMap = []): void { // Extract attributes from various possible locations $attributes = []; - - // 1. Direct keys at root level if (isset($data['identifier'])) { $attributes['identifier'] = (string)$data['identifier']; } - - // 2. From _attributes array (if it exists) if (isset($data['_attributes']) && is_array($data['_attributes'])) { foreach ($data['_attributes'] as $attrKey => $attrValue) { - // Clean up attribute keys (remove colons, etc.) $cleanKey = str_replace(':', '', $attrKey); - - // Check if this is a property definition to handle 'type' attribute correctly $isPropertyDefinition = ($sectionName === 'property_definitions'); - if ($cleanKey === 'type') { if ($isPropertyDefinition) { - // For property definitions, 'type' should remain as 'type' attribute $attributes['type'] = (string)$attrValue; } else { - // For other elements, 'type' becomes 'xsi:type' $attributes['xsi:type'] = (string)$attrValue; } } elseif (in_array($cleanKey, ['identifier', 'source', 'target', 'accessType'])) { @@ -923,37 +999,25 @@ private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?st } } } - - // 3. Look for xsi:type in various forms (including double underscore from import service) foreach (['xsi:type', 'xsi_type', '_xsi:type', '_xsi__type', '_type'] as $typeKey) { if (isset($data[$typeKey])) { - // Check if this is a property definition to handle type attributes correctly $isPropertyDefinition = ($sectionName === 'property_definitions'); - if ($typeKey === '_type' && $isPropertyDefinition && !isset($attributes['type'])) { - // For property definitions, _type becomes 'type' attribute $attributes['type'] = (string)$data[$typeKey]; break; } elseif (in_array($typeKey, ['xsi:type', 'xsi_type', '_xsi:type', '_xsi__type']) && !isset($attributes['xsi:type'])) { - // For other elements, these become 'xsi:type' attribute $attributes['xsi:type'] = (string)$data[$typeKey]; break; } } } - - // 4. Look for other attributes in various forms foreach (['source', 'target', 'accessType', 'type'] as $attrName) { if (isset($data[$attrName]) && !isset($attributes[$attrName])) { - // Check if this is a property definition to handle 'type' attribute correctly $isPropertyDefinition = ($sectionName === 'property_definitions'); - if ($attrName === 'type') { if ($isPropertyDefinition) { - // For property definitions, 'type' should remain as 'type' attribute $attributes['type'] = (string)$data[$attrName]; } elseif (!isset($attributes['xsi:type'])) { - // For other elements, 'type' becomes 'xsi:type' if not already set $attributes['xsi:type'] = (string)$data[$attrName]; } } else { @@ -961,8 +1025,6 @@ private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?st } } } - - // Add all found attributes to the XML node foreach ($attributes as $attrName => $attrValue) { if ($attrName === 'xsi:type') { $node->addAttribute('xsi:type', $attrValue, 'http://www.w3.org/2001/XMLSchema-instance'); @@ -970,20 +1032,16 @@ private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?st $node->addAttribute($attrName, $attrValue); } } - // Handle child elements foreach ($data as $key => $value) { - // Skip attributes and metadata fields if (in_array($key, ['identifier', 'xsi:type', 'xsi_type', '_xsi:type', '_type', 'source', 'target', 'accessType', 'type', '_attributes'])) { - continue; // Already handled as attributes + continue; } - if ($key === 'name' && is_array($value)) { $nameNode = $node->addChild('name'); if (isset($value['_value'])) { $nameNode[0] = (string)$value['_value']; } - // Handle xml:lang in various forms (including double underscore from import service) foreach (['xml:lang', '_xml:lang', '_xml__lang', 'xml_lang'] as $langKey) { if (isset($value[$langKey])) { $nameNode->addAttribute('xml:lang', $value[$langKey], 'http://www.w3.org/XML/1998/namespace'); @@ -995,7 +1053,6 @@ private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?st if (isset($value['_value'])) { $docNode[0] = (string)$value['_value']; } - // Handle xml:lang in various forms (including double underscore from import service) foreach (['xml:lang', '_xml:lang', '_xml__lang', 'xml_lang'] as $langKey) { if (isset($value[$langKey])) { $docNode->addAttribute('xml:lang', $value[$langKey], 'http://www.w3.org/XML/1998/namespace'); @@ -1009,7 +1066,6 @@ private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?st if (isset($value['_value'])) { $valueNode[0] = (string)$value['_value']; } - // Handle xml:lang in various forms (including double underscore from import service) foreach (['xml:lang', '_xml:lang', '_xml__lang', 'xml_lang'] as $langKey) { if (isset($value[$langKey])) { $valueNode->addAttribute('xml:lang', $value[$langKey], 'http://www.w3.org/XML/1998/namespace'); @@ -1018,6 +1074,43 @@ private function addCleanDataToXmlNode(\SimpleXMLElement $node, array $data, ?st } } } + // Add properties from root fields using propertyDefinitionMap ONLY if no properties were already processed + if (!empty($propertyDefinitionMap) && !isset($data['properties'])) { + $this->addPropertiesFromRootFields($node, $data, $propertyDefinitionMap); + } + } + + /** + * Add properties to XML node using propertyDefinitionMap from model + * + * @param \SimpleXMLElement $node XML node to add properties to + * @param array $object The object with root-level properties + * @param array $propertyDefinitionMap Map of property name => propertyDefinitionRef + */ + private function addPropertiesFromRootFields( + \SimpleXMLElement $node, + array $object, + array $propertyDefinitionMap + ): void { + // Find all root-level fields that match a propertyDefinitionMap entry + $properties = []; + foreach ($propertyDefinitionMap as $propRef => $propName) { + if (isset($object[$propName])) { + $properties[] = [ + 'propertyDefinitionRef' => $propRef, + 'value' => $object[$propName], + ]; + } + } + if (!empty($properties)) { + $propertiesNode = $node->addChild('properties'); + foreach ($properties as $property) { + $propertyNode = $propertiesNode->addChild('property'); + $propertyNode->addAttribute('propertyDefinitionRef', $property['propertyDefinitionRef']); + $valueNode = $propertyNode->addChild('value'); + $valueNode[0] = (string)$property['value']; + } + } } /** @@ -1066,11 +1159,26 @@ private function addPropertiesToXml(\SimpleXMLElement $node, array $properties): } } - // Also check in _attributes + // Also check in _attributes, but avoid duplicate if we already found one if (!$propDefRef && isset($property['_attributes']['propertyDefinitionRef'])) { $propDefRef = (string)$property['_attributes']['propertyDefinitionRef']; } + // Skip and clean up malformed attributes that would create invalid XML + if (isset($property['_attributes'][':propertyDefinitionRef'])) { + unset($property['_attributes'][':propertyDefinitionRef']); + } + // Also check for other malformed attribute patterns + $badAttrs = []; + foreach ($property['_attributes'] ?? [] as $attrName => $attrValue) { + if (str_starts_with($attrName, ':')) { + $badAttrs[] = $attrName; + } + } + foreach ($badAttrs as $badAttr) { + unset($property['_attributes'][$badAttr]); + } + if ($propDefRef) { $propertyNode->addAttribute('propertyDefinitionRef', $propDefRef); } @@ -1360,6 +1468,11 @@ private function runQualityAssuranceChecks(string $xmlString, array $sourceData) { $this->logger->info('Running Quality Assurance checks on exported XML'); + // DEBUG: Save XML to file for inspection + $debugPath = '/tmp/debug_export.xml'; + file_put_contents($debugPath, $xmlString); + $this->logger->info('DEBUG: Raw XML saved to ' . $debugPath . ' (size: ' . strlen($xmlString) . ' bytes)'); + try { $xml = new \SimpleXMLElement($xmlString); diff --git a/lib/Service/ArchiMateImportService.php b/lib/Service/ArchiMateImportService.php index 83d38b23..951afbfc 100644 --- a/lib/Service/ArchiMateImportService.php +++ b/lib/Service/ArchiMateImportService.php @@ -1,28 +1,150 @@ - * @copyright Copyright (c) Conduction - * @license AGPL-3.0-or-later https://www.gnu.org/licenses/agpl-3.0.html + * @author SoftwareCatalog Team + * @license AGPL-3.0 * @version 1.0.0 - * @link https://github.com/ConductionNL/SoftwareCatalog - * - * Provides generic XML → array conversion helpers for the AMEF import flow. + * @link https://github.com/nextcloud/softwarecatalog */ class ArchiMateImportService { + /** + * Configuration keys for ArchiMate processing + */ + private const CONFIG_KEYS = [ + 'archimate_register_id' => 'archimate_register_id', + 'archimate_schema_id' => 'archimate_schema_id', + 'archimate_model_schema_id' => 'archimate_model_schema_id' + ]; + + /** + * Performance optimization settings + */ + private const PERFORMANCE_OPTIMIZATIONS = [ + 'disable_validation' => true, + 'disable_events' => true, + 'disable_rbac' => false, // Keep RBAC for security + 'use_multi' => true, + 'xml_parse_flags' => LIBXML_NOCDATA | LIBXML_NONET, + 'memory_cleanup' => true, + 'parallel_processing' => true, + 'batch_size' => 1000, // Default batch size (will be adjusted intelligently) + 'parallel_batches' => 8, // Process 8 batches concurrently + 'max_batch_size_bytes' => 8388608, // 8 MB - safe under MySQL's 16 MB limit + 'min_batch_size' => 50, // Minimum batch size for very large objects + 'size_estimation_sample' => 10 // Sample size for estimating object sizes + ]; + + /** + * NOTE: Default schema IDs removed - all schema IDs must be configured via AMEF settings. + * The system will fail gracefully with clear error messages if configuration is missing. + */ + + /** + * Store last save operation timing breakdown for performance metrics + * + * @var array + */ + private array $lastSaveTimingBreakdown = []; + + /** + * Cache for camelCase property name conversions to avoid redundant processing + * + * @var array + */ + private array $camelCaseCache = []; + + /** + * Cache for identifier extraction patterns by section type + * + * @var array + */ + private array $identifierPatternCache = []; + + /** + * Flag to track if we've already logged finding a GEMMA type property + * + * @var bool + */ + private bool $gemmaTypePropertyFound = false; + + /** + * Cache for property definition maps to avoid rebuilding during import + * + * @var array|null + */ + private ?array $propertyDefinitionMapCache = null; + + /** + * Storage for the last save operation results + * Contains the structured return from ObjectService::saveObjects + */ + private ?array $lastSaveResult = null; + + /** + * Cached configuration values for performance optimization + */ + private ?array $cachedConfig = null; + + /** + * Constructor for ArchiMateImportService + * + * @param IAppConfig $config Nextcloud app configuration service + * @param IRootFolder $rootFolder Root folder service + * @param IUserSession $userSession User session service + * @param IAppManager $appManager App manager service + * @param ContainerInterface $container PSR-11 container interface + * @param LoggerInterface $logger Logger service + * @param SettingsService $settingsService Settings service for AMEF configuration + */ public function __construct( - private readonly LoggerInterface $logger + private readonly IAppConfig $config, + private readonly IRootFolder $rootFolder, + private readonly IUserSession $userSession, + private readonly IAppManager $appManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + private readonly SettingsService $settingsService, + private readonly OrganisationService $organisationService ) { } @@ -40,32 +162,39 @@ public function __construct( */ public function xmlToArray(\SimpleXMLElement $xml): array { - // Initialize result and legacy attribute bag for compatibility + // PERFORMANCE OPTIMIZATION: Initialize result only $result = []; - $attrBag = []; - - // Extract non-namespaced attributes → both underscored keys and attr bag - foreach ($xml->attributes() as $attrName => $attrValue) { - $underscoredKey = '_' . str_replace(':', '__', (string) $attrName); - $scalar = (string) $attrValue; - $result[$underscoredKey] = $scalar; - $attrBag[(string) $attrName] = $scalar; - } - // Extract namespaced attributes → both underscored keys and attr bag - foreach ($xml->getNameSpaces(true) as $prefix => $_) { - $attributes = $xml->attributes($prefix, true); + // OPTIMIZATION: Extract non-namespaced attributes (skip redundant processing) + $attributes = $xml->attributes(); + if (count($attributes) > 0) { + $attrBag = []; foreach ($attributes as $attrName => $attrValue) { - $underscoredKey = '_' . $prefix . '__' . str_replace(':', '__', (string) $attrName); - $scalar = (string) $attrValue; - $result[$underscoredKey] = $scalar; - $attrBag[$prefix . ':' . (string) $attrName] = $scalar; + $name = (string) $attrName; + $value = (string) $attrValue; + // OPTIMIZATION: Only create underscored key if needed (skip str_replace for simple names) + $underscoredKey = (strpos($name, ':') !== false) ? '_' . str_replace(':', '__', $name) : '_' . $name; + $result[$underscoredKey] = $value; + $attrBag[$name] = $value; } + $result['_attributes'] = $attrBag; } - // Preserve legacy _attributes bag if any attributes were found - if (!empty($attrBag)) { - $result['_attributes'] = $attrBag; + // OPTIMIZATION: Extract namespaced attributes (simplified processing) + foreach ($xml->getNameSpaces(true) as $prefix => $_) { + $nsAttributes = $xml->attributes($prefix, true); + if (count($nsAttributes) > 0) { + foreach ($nsAttributes as $attrName => $attrValue) { + $name = (string) $attrName; + $value = (string) $attrValue; + $underscoredKey = '_' . $prefix . '__' . $name; + $result[$underscoredKey] = $value; + if (!isset($result['_attributes'])) { + $result['_attributes'] = []; + } + $result['_attributes'][$prefix . ':' . $name] = $value; + } + } } // Extract children @@ -79,16 +208,18 @@ public function xmlToArray(\SimpleXMLElement $xml): array return $result; } - // Process child elements (merge by local-name) + // OPTIMIZATION: Process child elements with faster array operations foreach ($children as $child) { $childName = $child->getName(); $childValue = $this->xmlToArray($child); - if (!array_key_exists($childName, $result)) { + // OPTIMIZATION: Use isset instead of array_key_exists (faster) + if (!isset($result[$childName])) { $result[$childName] = $childValue; } else { - // Ensure multiple children become an array - if (!is_array($result[$childName]) || $this->isAssoc($result[$childName])) { + // OPTIMIZATION: Fast array conversion without expensive isAssoc check + if (!is_array($result[$childName]) || !isset($result[$childName][0])) { + // Convert to indexed array if it's a single value or associative array $result[$childName] = [$result[$childName]]; } $result[$childName][] = $childValue; @@ -105,6 +236,12 @@ public function xmlToArray(\SimpleXMLElement $xml): array } + /** + * Check if an array is associative (has string keys) + * + * @param array $array The array to check + * @return bool True if associative, false if indexed + */ private function isAssoc(mixed $value): bool { if (!is_array($value)) { @@ -112,6 +249,4359 @@ private function isAssoc(mixed $value): bool } return array_keys($value) !== range(0, count($value) - 1); } + + /** + * OPTIMIZED: Import ArchiMate XML file using OpenRegister-style performance optimization + * + * This method follows the same pattern as OpenRegister ImportService: + * 1. Parse ALL XML data first (single pass) + * 2. Transform to objects array (batch processing) + * 3. Single saveObjects() call with all objects + * + * Expected performance: <1 minute for 8000 objects (vs current 13 minutes) + * + * @param array $options Import options including file_path, fileName, etc. + * @return array Import results with detailed status + */ + public function importArchiMateFileFromPathOptimized(array $options = []): array + { + $startTime = microtime(true); + $startMemory = memory_get_usage(true); + + // DEBUG: Verify that the optimized import method is being called + $this->logger->info('GEMMA IMPORT DEBUG: Starting optimized import', $options); + + + // Starting OPTIMIZED ArchiMate XML import + + try { + // OPTIMIZATION: Cache all configuration once at start + $cacheStartTime = microtime(true); + $this->initializeCache(); + $cacheTime = microtime(true) - $cacheStartTime; + + // Cache initialization completed + + // STEP 1: Parse XML to array (same as before) + $filePath = $options['filePath'] ?? $options['file_path'] ?? ''; + if (empty($filePath) || !file_exists($filePath)) { + throw new \InvalidArgumentException("File not found: {$filePath}"); + } + + $parseStartTime = microtime(true); + $xmlData = $this->parseArchiMateXml($filePath); + $parseTime = microtime(true) - $parseStartTime; + + // PERFORMANCE OPTIMIZATION: Clean up memory after XML parsing + $memoryCleanupTime = 0; + if (self::PERFORMANCE_OPTIMIZATIONS['memory_cleanup']) { + $memoryCleanupStartTime = microtime(true); + $this->cleanupMemory(); + $memoryCleanupTime = microtime(true) - $memoryCleanupStartTime; + } + + // STEP 2: Extract model identifier + $modelIdentifierStartTime = microtime(true); + $modelIdentifier = $this->extractModelIdentifier($xmlData); + $modelIdentifierTime = microtime(true) - $modelIdentifierStartTime; + + // STEP 3: Parse ALL objects in one go (like CSV import) + $transformStartTime = microtime(true); + $allObjects = $this->transformArchiMateXmlToObjectsBatch($xmlData, $modelIdentifier); + $transformTime = microtime(true) - $transformStartTime; + + // Parsed and transformed all objects + + // STEP 4: Single saveObjects() call (like CSV import) + $saveStartTime = microtime(true); + $this->logger->info('GEMMA IMPORT DEBUG: About to save objects to database', [ + 'object_count' => count($allObjects) + ]); + $savedObjects = $this->saveObjectsToDatabase($allObjects); + $saveTime = microtime(true) - $saveStartTime; + + // Capture detailed save timing from internal tracking + $saveBreakdown = $this->lastSaveTimingBreakdown; + + $totalTime = microtime(true) - $startTime; + $itemsPerSecond = count($allObjects) / max($totalTime, 0.001); + + // Extract detailed error information from statistics + $statistics = $this->calculateOptimizedStatistics($savedObjects); + $detailedErrors = $this->extractDetailedErrors($statistics); + + // OPTIMIZED import completed successfully + + return [ + 'success' => true, + 'file_info' => [ + 'name' => $options['fileName'] ?? basename($filePath), + 'size' => filesize($filePath) + ], + 'performance_metrics' => [ + 'total_time_seconds' => round($totalTime, 3), + 'items_per_second' => round($itemsPerSecond, 1), + 'objects_processed' => count($allObjects), + 'timing_breakdown' => [ + 'cache_initialization_seconds' => round($cacheTime, 3), + 'xml_parsing_seconds' => round($parseTime, 3), + 'memory_cleanup_seconds' => round($memoryCleanupTime, 3), + 'model_identifier_extraction_seconds' => round($modelIdentifierTime, 3), + 'data_transformation_seconds' => round($transformTime, 3), + 'database_save_seconds' => round($saveTime, 3) + ], + 'save_operation_breakdown' => $saveBreakdown, + 'memory_usage' => [ + 'start_memory_mb' => round($startMemory / 1024 / 1024, 2), + 'current_memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'peak_memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2) + ], + 'processing_rates' => [ + 'xml_parse_objects_per_second' => round(count($allObjects) / max($parseTime, 0.001), 1), + 'transform_objects_per_second' => round(count($allObjects) / max($transformTime, 0.001), 1), + 'save_objects_per_second' => round(count($allObjects) / max($saveTime, 0.001), 1) + ] + ], + 'statistics' => $statistics, + 'detailed_errors' => $detailedErrors + ]; + + } catch (\Exception $e) { + $this->logger->error('OPTIMIZED ArchiMate import failed', [ + 'error' => $e->getMessage(), + 'file_path' => $options['file_path'] ?? 'unknown' + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage() + ]; + } + } + + /** + * Import ArchiMate XML file from path with model detection and round-trip fidelity + * + * This method handles the complete import workflow: + * 1. Parse XML to array (capturing all possible XML values) + * 2. Detect if model already exists or is new + * 3. Normalize data structure for storage as JSON blob + * 4. Convert to OpenRegister objects with proper @self structure + * 5. Save objects using ObjectService::saveObjects + * + * @param array $options Import options including file_path, fileName, etc. + * @return array Import results with detailed status + */ + public function importArchiMateFileFromPath(array $options = []): array + { + // Track start time and memory for performance metrics + $startTime = microtime(true); + $startMemory = memory_get_usage(true); + + // OPTIMIZATION: Cache configuration values once at the start + $this->initializeCache(); + + // Starting ArchiMate XML import with model detection + + try { + // STEP 1: Parse XML to array using the specialized import service + // This captures ALL possible XML values including attributes, text content, and nested elements + $filePath = $options['filePath'] ?? $options['file_path'] ?? ''; + + if (empty($filePath)) { + throw new \InvalidArgumentException('File path is required for import'); + } + + if (!file_exists($filePath)) { + throw new \InvalidArgumentException("File not found: {$filePath}"); + } + + $this->logger->info('Step 1: Parsing XML to array for complete data capture', ['filePath' => $filePath]); + $parseStartTime = microtime(true); + $xmlData = $this->parseArchiMateXml($filePath); + $parseTime = microtime(true) - $parseStartTime; + + // STEP 2: Extract model identifier and detect if model already exists + // This is critical for determining whether to create new or update existing model + $this->logger->info('Step 2: Extracting model identifier and checking for existing model'); + $validationStartTime = microtime(true); + $modelIdentifier = $this->extractModelIdentifier($xmlData); + $modelExists = $this->checkIfModelExists($modelIdentifier); + $validationTime = microtime(true) - $validationStartTime; + + // STEP 3: Normalize data structure for storage as JSON blob + // Store complete raw XML data for exact round-trip fidelity during export + $this->logger->info('Step 3: Normalizing data structure for JSON blob storage'); + $normalizedData = $this->normalizeArchiMateData($xmlData, $modelIdentifier); + + // STEP 4: Convert to OpenRegister objects with proper @self structure + // Each object must have @self with register, schema, and id for ObjectService::saveObjects + $this->logger->info('Step 4: Converting to OpenRegister objects with @self structure'); + $convertStartTime = microtime(true); + $objects = $this->convertToOpenRegisterObjects($normalizedData, $modelIdentifier); + $convertTime = microtime(true) - $convertStartTime; + + // STEP 5: Save objects using ObjectService::saveObjects + // This handles the actual database persistence with proper validation + $this->logger->info('Step 5: Saving objects to database using ObjectService::saveObjects'); + $savedObjects = $this->saveObjectsToDatabase($objects); + + // Calculate total time and memory usage + $totalTime = microtime(true) - $startTime; + $endMemory = memory_get_usage(true); + $peakMemory = memory_get_peak_usage(true); + + // Count objects by type for detailed statistics + $statistics = $this->calculateObjectStatistics($normalizedData, $savedObjects); + + // Calculate performance metrics + $totalObjects = $statistics['summary']['total_objects_created'] + $statistics['summary']['total_objects_updated']; + $itemsPerSecond = $totalObjects > 0 ? $totalObjects / $totalTime : 0; + + // Extract detailed error information from statistics + $detailedErrors = $this->extractDetailedErrors($statistics); + + // Prepare comprehensive result with detailed information + $result = [ + 'success' => true, + 'file_info' => [ + 'name' => $options['fileName'] ?? basename($filePath), + 'size' => filesize($filePath), + 'mime_type' => $options['mimeType'] ?? 'text/xml' + ], + 'processing_times' => [ + 'total_time_seconds' => round($totalTime, 3), + 'validation_time_seconds' => round($validationTime, 3), + 'parse_time_seconds' => round($parseTime, 3), + 'convert_time_seconds' => round($convertTime, 3), + 'performance_breakdown' => [ + 'validation_percent' => round(($validationTime / $totalTime) * 100, 1), + 'parse_percent' => round(($parseTime / $totalTime) * 100, 1), + 'convert_percent' => round(($convertTime / $totalTime) * 100, 1) + ] + ], + 'memory_usage' => [ + 'start_mb' => round($startMemory / 1024 / 1024, 1), + 'end_mb' => round($endMemory / 1024 / 1024, 1), + 'peak_mb' => round($peakMemory / 1024 / 1024, 2), + 'total_used_mb' => round(($endMemory - $startMemory) / 1024 / 1024, 1) + ], + 'statistics' => $statistics, + 'summary' => [ + 'total_objects_created' => $statistics['summary']['total_objects_created'], + 'total_objects_updated' => $statistics['summary']['total_objects_updated'], + 'total_objects_deleted' => $statistics['summary']['total_objects_deleted'], + 'total_objects_skipped' => $statistics['summary']['total_objects_skipped'], + 'total_errors' => $statistics['summary']['total_errors'] + ], + 'performance_metrics' => [ + 'items_per_second' => round($itemsPerSecond, 2), + 'processing_method' => 'synchronous_batch_processing', + 'batch_size_used' => 100, + 'dataset_size' => $totalObjects + ], + 'detailed_errors' => $detailedErrors + ]; + + $this->logger->info('ArchiMate XML import completed successfully', [ + 'model_identifier' => $modelIdentifier, + 'model_exists' => $modelExists, + 'imported_objects' => $totalObjects, + 'round_trip_fidelity' => 'enabled' + ]); + + return $result; + + } catch (\Exception $e) { + $this->logger->error('ArchiMate XML import failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'file_path' => $options['filePath'] ?? $options['file_path'] ?? 'unknown' + ]); + + return [ + 'success' => false, + 'message' => 'Import failed: ' . $e->getMessage(), + 'error' => $e->getMessage(), + 'step_failed' => 'unknown' // Will be refined with better error tracking + ]; + } + } + + /** + * Parse ArchiMate XML file to array using the import service + * + * @param string $filePath Path to XML file + * @return array Parsed XML data + */ + private function parseArchiMateXml(string $filePath): array + { + if (!file_exists($filePath)) { + throw new \InvalidArgumentException("File not found: {$filePath}"); + } + + // PERFORMANCE OPTIMIZATION: Use more efficient XML parsing + $xmlContent = file_get_contents($filePath); + if ($xmlContent === false) { + throw new \RuntimeException("Failed to read file: {$filePath}"); + } + + // PERFORMANCE OPTIMIZATION: Disable external entity loading for security and speed + $previousValue = libxml_disable_entity_loader(true); + + try { + // PERFORMANCE OPTIMIZATION: Use LIBXML_NOCDATA for faster parsing + $xml = new SimpleXMLElement($xmlContent, LIBXML_NOCDATA | LIBXML_NONET); + $result = $this->xmlToArray($xml); + + // PERFORMANCE OPTIMIZATION: Clear XML object from memory immediately + unset($xml); + + return $result; + } finally { + // Restore previous entity loader setting + libxml_disable_entity_loader($previousValue); + } + } + + /** + * Extract model identifier from parsed XML data + * + * This method looks for the model identifier in various locations within the XML: + * 1. Root level _attributes.identifier (most common) + * 2. Model element attributes + * 3. Fallback to generated identifier if none found + * + * @param array $xmlData Parsed XML data array + * @return string Model identifier for tracking and storage + */ + private function extractModelIdentifier(array $xmlData): string + { + $this->logger->debug('Extracting model identifier from XML data', [ + 'xml_keys' => array_keys($xmlData) + ]); + + // STEP 1: Try to find identifier in root attributes (most common location) + if (isset($xmlData['_attributes']['identifier'])) { + $modelId = $xmlData['_attributes']['identifier']; + $this->logger->info('Found model identifier in root attributes', [ + 'identifier' => $modelId + ]); + return $modelId; + } + + // STEP 2: Look for model element with identifier + if (isset($xmlData['model']) && is_array($xmlData['model'])) { + if (isset($xmlData['model']['_attributes']['identifier'])) { + $modelId = $xmlData['model']['_attributes']['identifier']; + $this->logger->info('Found model identifier in model element attributes', [ + 'identifier' => $modelId + ]); + return $modelId; + } + } + + // STEP 3: Look for archimate:model namespace (ArchiMate Tool format) + if (isset($xmlData['archimate:model']) && is_array($xmlData['archimate:model'])) { + if (isset($xmlData['archimate:model']['_attributes']['identifier'])) { + $modelId = $xmlData['archimate:model']['_attributes']['identifier']; + $this->logger->info('Found model identifier in archimate:model namespace', [ + 'identifier' => $modelId + ]); + return $modelId; + } + } + + // STEP 4: Generate fallback identifier if none found + $fallbackId = 'model-' . uniqid() . '-' . time(); + $this->logger->warning('No model identifier found, generating fallback', [ + 'fallback_id' => $fallbackId + ]); + + return $fallbackId; + } + + /** + * Check if a model already exists in the database + * + * @param string $modelIdentifier The model identifier to check + * @return bool True if model exists, false otherwise + */ + private function checkIfModelExists(string $modelIdentifier): bool + { + $this->logger->debug('Checking if model already exists', [ + 'model_identifier' => $modelIdentifier + ]); + + try { + // Get ObjectService to query existing objects + $objectService = $this->getObjectService(); + if (!$objectService) { + $this->logger->warning('ObjectService not available, assuming new model'); + return false; + } + + // Get AMEF configuration for register and schema IDs + $registerId = $this->getAmefRegisterId(); + $schemaId = $this->getAmefSchemaIdForType('model'); + + if (!$registerId || !$schemaId) { + $this->logger->warning('AMEF register or model schema not configured, assuming new model', [ + 'registerId' => $registerId, + 'schemaId' => $schemaId + ]); + return false; + } + + // Query for existing model objects with this identifier + // Use searchObjects with @self structure for proper querying + $query = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId + ], + 'archimate_id' => $modelIdentifier + ]; + + $existingModels = $objectService->searchObjects($query); + + $exists = !empty($existingModels); + + $this->logger->info('Model existence check completed', [ + 'model_identifier' => $modelIdentifier, + 'exists' => $exists, + 'found_count' => count($existingModels), + 'registerId' => $registerId, + 'schemaId' => $schemaId + ]); + + return $exists; + + } catch (\Exception $e) { + $this->logger->error('Error checking model existence', [ + 'model_identifier' => $modelIdentifier, + 'error' => $e->getMessage() + ]); + // If we can't check, assume new model to avoid data loss + return false; + } + } + + /** + * Normalize ArchiMate data structure for storage as JSON blob + * + * This method processes the parsed XML data and prepares it for storage: + * 1. Extracts model metadata (identifier, name, version, etc.) + * 2. Processes each section (elements, relationships, organizations, views, property_definitions) + * 3. Stores complete raw XML data for each item to ensure round-trip fidelity + * 4. Adds model identifier to each item for proper linking + * + * @param array $data Raw parsed XML data from import service + * @param string $modelIdentifier The model identifier for linking items + * @return array Normalized data structure ready for database storage + */ + private function normalizeArchiMateData(array $data, string $modelIdentifier): array + { + $this->logger->info('Normalizing ArchiMate data structure for JSON blob storage', [ + 'model_identifier' => $modelIdentifier + ]); + + // STEP 0: Extract propertyDefinition map and store in model metadata + $propertyDefinitionMap = $this->extractPropertyDefinitionMap($data); + + // Log property mapping for debugging + if (!empty($propertyDefinitionMap)) { + $this->logger->info('Property definitions extracted and mapped', [ + 'total_properties' => count($propertyDefinitionMap), + 'property_mapping' => $this->getPropertyNameMapping($propertyDefinitionMap) + ]); + } + + // Initialize normalized structure with model metadata + $normalized = [ + 'model_metadata' => [], + 'model_identifier' => $modelIdentifier, // Add model identifier for linking + 'elements' => [], + 'relationships' => [], + 'organizations' => [], + 'views' => [], + 'property_definitions' => [] + ]; + + // STEP 1: Extract and store model metadata + if (isset($data['_attributes'])) { + $normalized['model_metadata'] = $data['_attributes']; + } + // Also extract name and documentation from root level + if (isset($data['name'])) { + $normalized['model_metadata']['name'] = $data['name']; + } + if (isset($data['documentation'])) { + $normalized['model_metadata']['documentation'] = $data['documentation']; + } + if (isset($data['properties'])) { + $normalized['model_metadata']['properties'] = $data['properties']; + } + // Store propertyDefinitionMap in model_metadata + $normalized['model_metadata']['propertyDefinitionMap'] = $propertyDefinitionMap; + + $this->logger->debug('Extracted model metadata', [ + 'metadata_keys' => array_keys($normalized['model_metadata']), + 'has_name' => isset($normalized['model_metadata']['name']), + 'has_documentation' => isset($normalized['model_metadata']['documentation']) + ]); + + // STEP 2: Process each section and store complete raw XML data + $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; + $alternativeNames = [ + 'views' => ['views', 'diagrams'], + 'organizations' => ['organizations', 'organisation'], + 'property_definitions' => ['propertyDefinitions', 'property_definitions', 'propertydefinitions'] + ]; + foreach ($sections as $section) { + $sectionData = null; + $actualSectionName = null; + if (isset($data[$section])) { + $sectionData = $data[$section]; + $actualSectionName = $section; + } else { + if (isset($alternativeNames[$section])) { + foreach ($alternativeNames[$section] as $altName) { + if (isset($data[$altName])) { + $sectionData = $data[$altName]; + $actualSectionName = $altName; + break; + } + } + } + } + if ($sectionData !== null) { + $normalized[$section] = $this->extractSectionDataWithProperties($sectionData, $section, $modelIdentifier, $propertyDefinitionMap); + } + } + $this->logger->info('Data normalization completed', [ + 'model_identifier' => $modelIdentifier, + 'sections_processed' => $sections, + 'round_trip_fidelity' => 'enabled' + ]); + return $normalized; + } + + /** + * Extract data from a specific section, flatten properties, and store xml + * + * @param mixed $sectionData Section data from XML parsing + * @param string $sectionName Name of the section being processed + * @param string $modelIdentifier The model identifier for linking items + * @param array $propertyDefinitionMap Map of propertyDefinitionRef => property name + * @return array Extracted section data with complete XML preservation and flattened properties + */ + private function extractSectionDataWithProperties(mixed $sectionData, string $sectionName, string $modelIdentifier, array $propertyDefinitionMap): array + { + $extracted = []; + if (is_array($sectionData)) { + $items = $this->findItemsInSection($sectionData, $sectionName); + foreach ($items as $item) { + $identifier = $this->extractIdentifier($item, $sectionName); + if ($identifier) { + // OPTIMIZATION: Store XML data directly without expensive deep copy + // Start with base object structure + $object = [ + 'identifier' => $identifier, + 'section' => $sectionName, + 'model_identifier' => $modelIdentifier, + 'extracted_at' => time(), + 'xml' => $this->extractEssentialXmlData($item) // OPTIMIZATION: Store only essential XML data + ]; + + // Extract name from XML if it exists + if (isset($item['name'])) { + if (is_array($item['name']) && isset($item['name']['_value'])) { + $object['name'] = $item['name']['_value']; + } elseif (is_string($item['name'])) { + $object['name'] = $item['name']; + } + } + + // Extract documentation from XML if it exists and set to summary + if (isset($item['documentation'])) { + if (is_array($item['documentation']) && isset($item['documentation']['_value'])) { + $object['summary'] = $item['documentation']['_value']; + } elseif (is_string($item['documentation'])) { + $object['summary'] = $item['documentation']; + } + } + + // Flatten properties to root fields using the propertyDefinitionMap + if (isset($item['properties']) && isset($item['properties']['property'])) { + $props = $item['properties']['property']; + $processedProperties = []; + if (isset($props[0])) { + // Multiple properties + foreach ($props as $prop) { + $defRef = $prop['_attributes']['propertyDefinitionRef'] ?? null; + $value = $prop['value']['_value'] ?? $prop['value'] ?? null; + if ($defRef && isset($propertyDefinitionMap[$defRef])) { + $name = $propertyDefinitionMap[$defRef]; + $camelCaseName = $this->convertToCamelCase($name); + $object[$camelCaseName] = $value; + + // Store property mapping for reference + if (!isset($object['_propertyMapping'])) { + $object['_propertyMapping'] = []; + } + $object['_propertyMapping'][$camelCaseName] = $name; + + // If this property is 'Object ID', set slug for later use + if (strtolower($name) === 'object id') { + $object['_slug'] = $value; // Store temporarily, will be moved to @self.slug later + } + } + } + } elseif (isset($props['_attributes']['propertyDefinitionRef'])) { + // Single property + $defRef = $props['_attributes']['propertyDefinitionRef']; + $value = $props['value']['_value'] ?? $props['value'] ?? null; + if ($defRef && isset($propertyDefinitionMap[$defRef])) { + $name = $propertyDefinitionMap[$defRef]; + $camelCaseName = $this->convertToCamelCase($name); + $object[$camelCaseName] = $value; + + // Store property mapping for reference + if (!isset($object['_propertyMapping'])) { + $object['_propertyMapping'] = []; + } + $object['_propertyMapping'][$camelCaseName] = $name; + + $processedProperties[] = [ + 'original' => $name, + 'camelCase' => $camelCaseName, + 'value' => $value + ]; + + if (strtolower($name) === 'object id') { + $object['_slug'] = $value; // Store temporarily, will be moved to @self.slug later + } + } + + // OPTIMIZATION: Removed debug logging from tight loop for performance + } + } + $extracted[$identifier] = $object; + } + } + } + return $extracted; + } + + /** + * Convert normalized data to OpenRegister objects with @self structure + * + * This method creates OpenRegister objects from the normalized ArchiMate data: + * 1. Creates a model object with proper @self structure + * 2. Creates section objects for each item (elements, relationships, etc.) + * 3. Ensures each object has the required @self structure for ObjectService::saveObjects + * 4. Links all objects to the parent model via model_identifier + * + * @param array $normalizedData Normalized ArchiMate data with model_identifier + * @param string $modelIdentifier The model identifier for linking objects + * @return array Array of OpenRegister objects with proper @self structure + */ + private function convertToOpenRegisterObjects(array $normalizedData, string $modelIdentifier): array + { + $this->logger->info('Converting to OpenRegister objects with @self structure', [ + 'model_identifier' => $modelIdentifier + ]); + + $objects = []; + + // STEP 1: Convert model metadata to model object + if (!empty($normalizedData['model_metadata'])) { + $this->logger->debug('Creating model object from metadata'); + $objects[] = $this->createModelObject($normalizedData['model_metadata'], $modelIdentifier); + } + + // STEP 2: Convert each section to individual objects + $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; + + // OPTIMIZATION: Removed excessive debug logging from tight loops + $sectionCounts = []; + foreach ($sections as $section) { + if (!empty($normalizedData[$section]) && is_array($normalizedData[$section])) { + $sectionCounts[$section] = count($normalizedData[$section]); + foreach ($normalizedData[$section] as $identifier => $data) { + $objects[] = $this->createSectionObject($section, $identifier, $data, $modelIdentifier); + } + } else { + $sectionCounts[$section] = 0; + } + } + + // Single consolidated log entry + $this->logger->debug('Sections processed', $sectionCounts); + + $this->logger->info('Conversion to OpenRegister objects completed', [ + 'model_identifier' => $modelIdentifier, + 'total_objects' => count($objects), + 'sections_processed' => $sections + ]); + + return $objects; + } + + /** + * Create model object with @self structure + * + * @param array $metadata Model metadata + * @param string $modelIdentifier Model identifier + * @return array Model object with @self structure + */ + private function createModelObject(array $metadata, string $modelIdentifier): array + { + // OPTIMIZATION: Use cached configuration values + $registerId = $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."); + $schemaId = $this->cachedConfig['schemaIds']['model'] ?? throw new \RuntimeException("Schema ID for 'model' not found in cached configuration. Please ensure AMEF configuration is properly initialized."); + + // Create object with @self structure and metadata at root level (no JSON serialization) + $object = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId, + 'id' => $metadata['identifier'] ?? uniqid('model_'), + 'owner' => $this->getCurrentUserId(), + 'organisation' => $this->getCurrentOrganisation(), + 'published' => date('Y-m-d\TH:i:s\Z') + ], + 'identifier' => $metadata['identifier'] ?? '', + 'section' => 'model', + 'model_identifier' => $modelIdentifier + ]; + + // Merge metadata directly at root level + return array_merge($object, $metadata); + } + + /** + * Create section object with @self structure and flattened XML data + * + * @param string $section Section name + * @param string $identifier Item identifier + * @param array $data Item data (already contains XML data at root level) + * @param string $modelIdentifier Model identifier for linking + * @return array Section object with @self structure + */ + private function createSectionObject(string $section, string $identifier, array $data, string $modelIdentifier): array + { + // OPTIMIZATION: Use cached configuration values + $registerId = $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."); + $schemaId = $this->cachedConfig['schemaIds'][$section] ?? $this->getSchemaIdForSection($section); + + // FIXED: Use objectId as main ID and AMEF identifier as slug + $objectId = null; + $slug = null; + + // Priority 1: Check for objectId property (flattened from "Object ID") + if (isset($data['objectId'])) { + $objectId = $data['objectId']; + $slug = $identifier; // Use AMEF identifier as slug + } + // Priority 2: Check for temporary _slug field (legacy support) + elseif (isset($data['_slug'])) { + $objectId = $data['_slug']; + $slug = $identifier; // Use AMEF identifier as slug + unset($data['_slug']); // Remove the temporary field + } + // Priority 3: Check for direct "Object ID" property + elseif (isset($data['Object ID'])) { + $objectId = $data['Object ID']; + $slug = $identifier; // Use AMEF identifier as slug + } + // Fallback: Use AMEF identifier as both ID and extract clean UUID for slug + else { + $objectId = $identifier; + // Extract clean UUID from AMEF identifier (remove "id-" prefix if present) + if ($identifier && str_starts_with($identifier, 'id-')) { + $slug = substr($identifier, 3); // Remove "id-" prefix + } else { + $slug = $identifier; + } + } + + // Create object with @self structure using correct ID and slug + $object = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId, + 'id' => $objectId, // Now using objectId as main ID + 'slug' => $slug, // Now using AMEF identifier as slug + 'owner' => $this->getCurrentUserId(), + 'organisation' => $this->getCurrentOrganisation(), + 'published' => date('Y-m-d\TH:i:s\Z') + ] + ]; + + // Merge XML data directly at root level (data already contains identifier, section, model_identifier) + return array_merge($object, $data); + } + + /** + * Save objects to database using ObjectService::saveObjects + * + * @param array $objects Objects to save + * @return array Saved objects + */ + private function saveObjectsToDatabase(array $objects): array + { + $saveStartTime = microtime(true); + + // DEBUG: Log basic object info before sending to ObjectService + $this->logger->info('saveObjectsToDatabase DEBUG', [ + 'total_objects_to_save' => count($objects), + 'first_object_sample' => !empty($objects) ? [ + 'id' => $objects[0]['@self']['id'] ?? 'no-id', + 'register' => $objects[0]['@self']['register'] ?? 'no-register', + 'schema' => $objects[0]['@self']['schema'] ?? 'no-schema', + 'section' => $objects[0]['section'] ?? 'no-section' + ] : null, + 'object_sections' => array_count_values(array_column($objects, 'section')) + ]); + + + $serviceInitStartTime = microtime(true); + $objectService = $this->getObjectService(); + if (!$objectService) { + throw new \RuntimeException('ObjectService not available'); + } + $serviceInitTime = microtime(true) - $serviceInitStartTime; + + // ENHANCEMENT: Process GEMMA Referentiecomponent-Standaard relationships before saving + $gemmaProcessingStartTime = microtime(true); + $objects = $this->processGemmaReferenceComponentStandards($objects); + $gemmaProcessingTime = microtime(true) - $gemmaProcessingStartTime; + + // Saving objects to database + + // OPTIMIZATION: Use cached register ID + $registerId = $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."); + + + + // HYBRID OPTIMIZATION: Choose best strategy based on dataset size + // Small datasets: Direct to ObjectService (avoids batching overhead) + // Large datasets: Use our intelligent batching (better performance for bulk operations) + $batchProcessingStartTime = microtime(true); + $objectCount = count($objects); + + if ($objectCount < 2000) { + // Small dataset: Let ObjectService handle everything directly + $result = $this->saveObjectsDirectToService($objects, $objectService, $registerId); + } else { + // Large dataset: Use our intelligent batching for better performance + $result = $this->saveObjectsInParallelBatches($objects, $objectService, $registerId); + } + $batchProcessingTime = microtime(true) - $batchProcessingStartTime; + + $totalSaveTime = microtime(true) - $saveStartTime; + + + + // Database save completed + + // Store timing breakdown for performance metrics + $this->lastSaveTimingBreakdown = [ + 'total_save_seconds' => round($totalSaveTime, 3), + 'service_init_seconds' => round($serviceInitTime, 3), + 'gemma_processing_seconds' => round($gemmaProcessingTime, 3), + 'batch_processing_seconds' => round($batchProcessingTime, 3), + 'objects_saved' => count($result), + 'save_rate_objects_per_second' => round(count($objects) / max($totalSaveTime, 0.001), 1) + ]; + + return $result; + } + + /** + * Save objects directly to ObjectService without custom batching + * Lets ObjectService handle all batching, throttling, and optimization internally + * + * @param array $objects Array of objects to save + * @param ObjectService $objectService ObjectService instance + * @param int $registerId Register ID + * @return array Array of saved objects + */ + private function saveObjectsDirectToService(array $objects, ObjectService $objectService, int $registerId): array + { + try { + // Single call to ObjectService - let it handle everything + $saveResult = $objectService->saveObjects( + objects: $objects, + register: $registerId, + schema: null, // Mixed schemas supported now + rbac: true, // Enable proper RBAC + multi: true, // Enable multi-processing if available + validation: true, // Enable validation + events: true // Enable events + ); + + // Store result for statistics + $this->lastSaveResult = $saveResult; + + // DEBUG: Log ObjectService save result to understand skipping behavior + $this->logger->info('ObjectService save result DEBUG', [ + 'total_objects_sent' => count($objects), + 'saved_count' => count($saveResult['saved'] ?? []), + 'updated_count' => count($saveResult['updated'] ?? []), + 'unchanged_count' => count($saveResult['unchanged'] ?? []), + 'skipped_count' => count($saveResult['skipped'] ?? []), + 'invalid_count' => count($saveResult['invalid'] ?? []), + 'result_keys' => array_keys($saveResult), + 'first_unchanged_sample' => isset($saveResult['unchanged'][0]) ? [ + 'id' => $saveResult['unchanged'][0]->getUuid(), + 'object_type' => get_class($saveResult['unchanged'][0]) + ] : null + ]); + + // Return combined saved and updated objects + return array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [], + $saveResult['unchanged'] ?? [] + ); + + } catch (\Exception $e) { + $this->logger->error('Error in direct ObjectService save', [ + 'error' => $e->getMessage(), + 'object_count' => count($objects) + ]); + throw $e; + } + } + + /** + * Save objects in parallel batches for maximum performance (DEPRECATED) + * + * @param array $objects Array of objects to save + * @param ObjectService $objectService ObjectService instance + * @param int $registerId Register ID + * @return array Array of saved objects + */ + private function saveObjectsInParallelBatches(array $objects, ObjectService $objectService, int $registerId): array + { + $batchSize = self::PERFORMANCE_OPTIMIZATIONS['batch_size']; + $parallelBatches = self::PERFORMANCE_OPTIMIZATIONS['parallel_batches']; + + // INTELLIGENT BATCH SIZING: Create size-aware batches instead of fixed-size chunks + $chunks = $this->createIntelligentBatches($objects); + $totalChunks = count($chunks); + + // Batch processing initialized + + $allResults = []; + $processedChunks = 0; + + // Accumulate statistics from all chunks (using new format) + $aggregatedStats = [ + 'saved' => [], + 'updated' => [], + 'unchanged' => [], + 'invalid' => [] + ]; + + // Process chunks sequentially but with larger batch sizes for better performance + foreach ($chunks as $chunkIndex => $chunk) { + $chunkInputCount = count($chunk); + + try { + $saveResult = $objectService->saveObjects( + objects: $chunk, + register: $registerId, + schema: null, + rbac: self::PERFORMANCE_OPTIMIZATIONS['disable_rbac'] ? false : true, + multi: self::PERFORMANCE_OPTIMIZATIONS['use_multi'], + validation: !self::PERFORMANCE_OPTIMIZATIONS['disable_validation'], + events: !self::PERFORMANCE_OPTIMIZATIONS['disable_events'] + ); + + + + // Calculate totals received back from this chunk + $chunkTotalReceived = count($saveResult['saved'] ?? []) + + count($saveResult['updated'] ?? []) + + count($saveResult['unchanged'] ?? []) + + count($saveResult['invalid'] ?? []); + + // Accumulate statistics from this chunk + $aggregatedStats['saved'] = array_merge($aggregatedStats['saved'], $saveResult['saved'] ?? []); + $aggregatedStats['updated'] = array_merge($aggregatedStats['updated'], $saveResult['updated'] ?? []); + $aggregatedStats['unchanged'] = array_merge($aggregatedStats['unchanged'], $saveResult['unchanged'] ?? []); + $aggregatedStats['invalid'] = array_merge($aggregatedStats['invalid'], $saveResult['invalid'] ?? []); + + $savedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [] + ); + + $allResults = array_merge($allResults, $savedObjects); + + $processedChunks++; + + } catch (\Exception $e) { + $this->logger->error('Error processing chunk', [ + 'chunk_index' => $chunkIndex, + 'error' => $e->getMessage() + ]); + // Continue with other chunks + } + + // Memory cleanup between chunks + if (self::PERFORMANCE_OPTIMIZATIONS['memory_cleanup']) { + $this->cleanupMemory(); + } + } + + // Store the aggregated result for statistics calculation + $this->lastSaveResult = $aggregatedStats; + + $totalObjectsProcessed = count($aggregatedStats['saved']) + count($aggregatedStats['updated']) + count($aggregatedStats['unchanged']) + count($aggregatedStats['invalid']); + + // Batch processing completed + + // Log critical discrepancy if found + if (count($objects) != $totalObjectsProcessed) { + $this->logger->critical('OBJECT COUNT MISMATCH DETECTED', [ + 'objects_sent_to_openregister' => count($objects), + 'objects_processed_by_openregister' => $totalObjectsProcessed, + 'missing_objects' => count($objects) - $totalObjectsProcessed, + 'this_explains_the_781_missing_objects' => true + ]); + } + + return $allResults; + } + + /** + * Save objects in a single batch (fallback method) + * + * @param array $objects Array of objects to save + * @param ObjectService $objectService ObjectService instance + * @param int $registerId Register ID + * @return array Array of saved objects + */ + private function saveObjectsInSingleBatch(array $objects, ObjectService $objectService, int $registerId): array + { + // Using single batch processing + + + + $saveResult = $objectService->saveObjects( + objects: $objects, + register: $registerId, + schema: null, + rbac: self::PERFORMANCE_OPTIMIZATIONS['disable_rbac'] ? false : true, + multi: self::PERFORMANCE_OPTIMIZATIONS['use_multi'], + validation: !self::PERFORMANCE_OPTIMIZATIONS['disable_validation'], + events: !self::PERFORMANCE_OPTIMIZATIONS['disable_events'] + ); + + + + // Store the save result for later access to statistics + $this->lastSaveResult = $saveResult; + + // Extract saved objects from the new structured return format + $savedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [] + ); + + // Objects saved successfully + + // Validation errors logged if any + + // Unchanged objects noted if any + + // Return the combined saved and updated objects (maintaining backward compatibility) + return $savedObjects; + } + + /** + * Get ObjectService from container + * + * @return ObjectService|null ObjectService instance or null if not available + */ + private function getObjectService(): ?ObjectService + { + if (!$this->appManager->isInstalled('openregister')) { + return null; + } + + try { + return $this->container->get(ObjectService::class); + } catch (\Exception $e) { + $this->logger->warning('Failed to get ObjectService', [ + 'error' => $e->getMessage() + ]); + return null; + } + } + + /** + * Initialize cached configuration values for performance optimization + * + * @return void + */ + private function initializeCache(): void + { + if ($this->cachedConfig !== null) { + return; // Already cached + } + + $this->cachedConfig = [ + 'userId' => $this->userSession->getUser()?->getUID(), + 'organisation' => 'default', + 'registerId' => $this->getAmefRegisterId(), + 'schemaIds' => [ + 'model' => $this->getAmefSchemaIdForType('model'), + 'element' => $this->getAmefSchemaIdForType('element'), + 'relationship' => $this->getAmefSchemaIdForType('relationship'), + 'view' => $this->getAmefSchemaIdForType('view'), + 'organization' => $this->getAmefSchemaIdForType('organization'), + 'property_definition' => $this->getAmefSchemaIdForType('property_definition') + // NOTE: 'property' removed - properties are never root-level AMEF objects, only nested within other elements + ] + ]; + } + + /** + * Log current memory usage for performance monitoring + * + * @param string $stage Description of the current processing stage + * @return void + */ + private function logMemoryUsage(string $stage): void + { + // Check if debug logging is available (Nextcloud logger doesn't have isDebug method) + $memoryUsage = memory_get_usage(true); + $memoryPeak = memory_get_peak_usage(true); + $memoryLimit = ini_get('memory_limit'); + + $this->logger->debug("Memory usage at: {$stage}", [ + 'current_mb' => round($memoryUsage / 1024 / 1024, 2), + 'peak_mb' => round($memoryPeak / 1024 / 1024, 2), + 'limit' => $memoryLimit + ]); + } + + /** + * Clean up memory by forcing garbage collection + * + * @return void + */ + private function cleanupMemory(): void + { + if (function_exists('gc_collect_cycles')) { + $cycles = gc_collect_cycles(); + // Use PSR-3 standard logging instead of isDebug() check + $this->logger->debug('Garbage collection completed', [ + 'cycles_collected' => $cycles + ]); + } + } + + /** + * Get current user ID from cache + * + * @return string|null Current user ID or null if not authenticated + */ + private function getCurrentUserId(): ?string + { + return $this->cachedConfig['userId'] ?? null; + } + + /** + * Get current organisation UUID from OrganisationService + * + * @return string Organisation UUID + */ + private function getCurrentOrganisation(): string + { + try { + $this->logger->info('Getting default organisation from OrganisationService'); + $defaultOrganisation = $this->organisationService->ensureDefaultOrganisation(); + $uuid = $defaultOrganisation->getUuid(); + + + $this->logger->info('Got default organisation UUID: ' . $uuid); + return $uuid; + } catch (\Exception $e) { + $this->logger->error('Failed to get default organisation: ' . $e->getMessage()); + $this->logger->error('Exception trace: ' . $e->getTraceAsString()); + // Fallback to cached value or 'default' string + return $this->cachedConfig['organisation'] ?? 'default'; + } + } + + /** + * Get AMEF configuration from app config + * + * @return array AMEF configuration + */ + public function getAmefConfig(): array + { + $this->logger->info('Getting AMEF configuration'); + + try { + // Get configuration from app config using the correct method + $config = $this->config->getValueString('softwarecatalog', 'amef_config', '{}'); + $decoded = json_decode($config, true); + + if (!is_array($decoded)) { + // Fallback to individual config values for backward compatibility + $decoded = [ + 'register_id' => $this->config->getValueString('softwarecatalog', 'amef_register', ''), + 'model_schema_id' => $this->config->getValueString('softwarecatalog', 'amef_model_schema', ''), + 'elements_schema' => $this->config->getValueString('softwarecatalog', 'amef_elements_schema', ''), + 'relationships_schema' => $this->config->getValueString('softwarecatalog', 'amef_relationships_schema', ''), + 'views_schema' => $this->config->getValueString('softwarecatalog', 'amef_views_schema', ''), + 'organizations_schema' => $this->config->getValueString('softwarecatalog', 'amef_organizations_schema', ''), + 'folders_schema' => $this->config->getValueString('softwarecatalog', 'amef_folders_schema', ''), + 'property_definitions_schema' => $this->config->getValueString('softwarecatalog', 'amef_property_definitions_schema', '') + ]; + } + + return $decoded; + } catch (\Exception $e) { + $this->logger->error('Failed to get AMEF configuration', [ + 'error' => $e->getMessage() + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage() + ]; + } + } + + /** + * Get AMEF register ID from configuration + * + * @return int|null The register ID or null if not configured + */ + private function getAmefRegisterId(): ?int + { + // Retrieve AMEF configuration + $amefConfig = $this->getAmefConfig(); + + // Try JSON config keys first: support both 'register_id' and 'register' + $rawRegisterId = $amefConfig['register_id'] + ?? $amefConfig['register'] + ?? null; + + // Fallback to legacy individual app config keys if not present in JSON + if ($rawRegisterId === null || $rawRegisterId === '') { + $rawRegisterId = $this->config->getValueString('softwarecatalog', 'amef_register', '') + ?: $this->config->getValueString('softwarecatalog', 'amef_register_id', ''); + } + + // Validate and normalize to positive int + if ($rawRegisterId !== null && $rawRegisterId !== '' && is_numeric((string) $rawRegisterId)) { + $registerId = (int) $rawRegisterId; + return $registerId > 0 ? $registerId : null; + } + + return null; + } + + /** + * Get AMEF schema ID for a specific ArchiMate type + * + * This method retrieves the schema ID for a given ArchiMate type from the AMEF configuration. + * It looks for the schema ID using the pattern '{type}_schema' in the configuration. + * + * @param string $archiMateType The ArchiMate type (e.g., 'element', 'organization', 'relationship') + * @return int|null The schema ID for the given type or null if not configured + */ + private function getAmefSchemaIdForType(string $archiMateType): ?int + { + // Get AMEF configuration + $amefConfig = $this->getAmefConfig(); + + // Normalize plural → singular and handle the actual config structure + $typeMapping = [ + 'elements' => 'element', + 'organizations' => 'organization', + // Accept both 'relationships' (AMEF wording) and UI term 'relation' + 'relationships' => 'relation', + 'views' => 'view', + 'models' => 'model', + // NOTE: 'properties' mapping removed - properties are never root-level AMEF objects + 'property_definitions' => 'property_definition' + ]; + $normalizedType = $typeMapping[$archiMateType] ?? $archiMateType; + + // Candidate keys: match the actual config structure + $schemaKeyCandidatesByType = [ + 'element' => ['element_schema'], + 'organization' => ['organization_schema'], + 'relationship' => ['relation_schema'], + 'view' => ['view_schema'], + 'model' => ['model_schema'], + 'property_definition' => ['property_definition_schema'] + // NOTE: 'property' removed - properties are never root-level AMEF objects, only nested within other elements + ]; + + $candidates = $schemaKeyCandidatesByType[$normalizedType] ?? [$normalizedType . '_schema']; + + // Try JSON config with the actual keys + foreach ($candidates as $key) { + if (array_key_exists($key, $amefConfig)) { + $raw = $amefConfig[$key]; + if ($raw !== '' && $raw !== null && is_numeric((string) $raw)) { + $id = (int) $raw; + if ($id > 0) { + return $id; + } + } + } + } + + // Fallback to legacy individual app config keys if not present in JSON + foreach ($candidates as $key) { + $raw = $this->config->getValueString('softwarecatalog', 'amef_' . $key, '') + ?: $this->config->getValueString('softwarecatalog', $key, ''); + if ($raw !== '' && is_numeric((string) $raw)) { + $id = (int) $raw; + if ($id > 0) { + return $id; + } + } + } + + return null; + } + + /** + * Get schema ID for a section using SettingsService (no hardcoded fallbacks) + * + * @param string $section Section name + * @return int Schema ID + * @throws \RuntimeException If schema ID is not configured + */ + private function getSchemaIdForSection(string $section): int + { + // Map section names to object types for SettingsService + $objectTypeMapping = [ + 'elements' => 'element', + 'relationships' => 'relationship', + 'views' => 'view', + 'organizations' => 'organization', + 'property_definitions' => 'property_definition' + ]; + + $objectType = $objectTypeMapping[$section] ?? $section; + $schemaId = $this->settingsService->getSchemaIdForObjectType($objectType); + + // Ensure schema ID is configured - no hardcoded fallbacks + if ($schemaId === null) { + throw new \RuntimeException("Schema ID for section '{$section}' is not configured. Please configure all AMEF schema IDs via the admin interface. Expected object type: '{$objectType}'"); + } + + return $schemaId; + } + + /** + * Extract propertyDefinitions from the parsed XML and build a map + * + * @param array $data Parsed XML data + * @return array Map of propertyDefinitionRef => property name + */ + private function extractPropertyDefinitionMap(array $data): array + { + // OPTIMIZATION: Return cached property definition map if available + if ($this->propertyDefinitionMapCache !== null) { + return $this->propertyDefinitionMapCache; + } + + $map = []; + // Find propertyDefinitions section (handle possible alternative names) + $propertyDefs = null; + if (isset($data['propertyDefinitions'])) { + $propertyDefs = $data['propertyDefinitions']; + } elseif (isset($data['property_definitions'])) { + $propertyDefs = $data['property_definitions']; + } elseif (isset($data['propertyDefinitions'])) { + $propertyDefs = $data['propertyDefinitions']; + } + if ($propertyDefs && isset($propertyDefs['propertyDefinition'])) { + $defs = $propertyDefs['propertyDefinition']; + if (isset($defs[0])) { + // Array of propertyDefinition + foreach ($defs as $def) { + if (isset($def['_attributes']['identifier']) && isset($def['name'])) { + $map[$def['_attributes']['identifier']] = is_array($def['name']) && isset($def['name']['_value']) ? $def['name']['_value'] : $def['name']; + } + } + } elseif (isset($defs['_attributes']['identifier']) && isset($defs['name'])) { + // Single propertyDefinition + $map[$defs['_attributes']['identifier']] = is_array($defs['name']) && isset($defs['name']['_value']) ? $defs['name']['_value'] : $defs['name']; + } + } + + // OPTIMIZATION: Cache the result for subsequent calls during the same import + $this->propertyDefinitionMapCache = $map; + + return $map; + } + + /** + * Get property mapping information for debugging and reference + * + * This method returns a mapping of original property names to their camelCase equivalents + * which can be useful for understanding how properties are being processed. + * + * @param array $propertyDefinitionMap The original property definition map + * @return array Mapping of original names to camelCase names + */ + public function getPropertyNameMapping(array $propertyDefinitionMap): array + { + $mapping = []; + + foreach ($propertyDefinitionMap as $propertyRef => $originalName) { + $mapping[$originalName] = $this->convertToCamelCase($originalName); + } + + return $mapping; + } + + /** + * Convert property names with spaces to camelCase for better database compatibility + * + * Examples: + * - "Object ID" -> "objectId" + * - "Business Unit" -> "businessUnit" + * - "System Name" -> "systemName" + * + * @param string $propertyName Property name that may contain spaces + * @return string CamelCase version of the property name + */ + private function convertToCamelCase(string $propertyName): string + { + // OPTIMIZATION: Check cache first to avoid redundant conversions + if (isset($this->camelCaseCache[$propertyName])) { + return $this->camelCaseCache[$propertyName]; + } + + // Remove any leading/trailing whitespace + $propertyName = trim($propertyName); + + // Split by spaces and convert to camelCase + $words = explode(' ', $propertyName); + + if (count($words) === 1) { + // Single word, just lowercase it + $result = strtolower($words[0]); + } else { + // First word is lowercase, subsequent words are capitalized + $camelCase = strtolower($words[0]); + + for ($i = 1; $i < count($words); $i++) { + $camelCase .= ucfirst(strtolower($words[$i])); + } + + $result = $camelCase; + } + + // OPTIMIZATION: Cache the result for future use + $this->camelCaseCache[$propertyName] = $result; + + return $result; + } + + /** + * Calculate optimized statistics for performance reporting + * + * @param array $savedObjects Saved objects from ObjectService::saveObjects + * @return array Statistics array + */ + private function calculateOptimizedStatistics(array $savedObjects): array + { + // Initialize statistics structure for detailed error extraction + $statistics = [ + 'elements' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => []], + 'organizations' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => []], + 'relationships' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => []], + 'views' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => []], + 'property_definitions' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => []], + 'summary' => [ + 'total_objects_created' => 0, + 'total_objects_updated' => 0, + 'total_objects_deleted' => 0, + 'total_objects_unchanged' => 0, + 'total_errors' => 0 + ] + ]; + + if ($this->lastSaveResult !== null) { + $saveResult = $this->lastSaveResult; + + // Process objects by section type similar to calculateObjectStatistics + $allProcessedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [], + $saveResult['unchanged'] ?? $saveResult['skipped'] ?? [], + // For invalid objects, extract the original object from the error structure + array_map(fn($item) => $item['object'] ?? [], $saveResult['invalid'] ?? []) + ); + + foreach ($allProcessedObjects as $object) { + // Convert ObjectEntity to array if needed + if (is_object($object) && method_exists($object, 'jsonSerialize')) { + $object = $object->jsonSerialize(); + } + + $sectionType = $object['section'] ?? 'elements'; // Default to elements if section not found + + // Map section types to statistics keys + $sectionKey = match($sectionType) { + 'elements' => 'elements', + 'relationships' => 'relationships', + 'organizations' => 'organizations', + 'views' => 'views', + 'property_definitions' => 'property_definitions', + default => 'elements' // Default fallback + }; + + if (!isset($statistics[$sectionKey])) { + continue; // Skip unknown section types + } + + // Determine if this object was created, updated, or had errors + $objectId = $object['@self']['id'] ?? $object['identifier'] ?? null; + + // Check if this object is in the saved (created) list + $wasCreated = !empty(array_filter($saveResult['saved'] ?? [], + fn($saved) => (is_object($saved) && method_exists($saved, 'getUuid') ? $saved->getUuid() : null) === $objectId)); + + // Check if this object is in the updated list + $wasUpdated = !empty(array_filter($saveResult['updated'] ?? [], + fn($updated) => (is_object($updated) && method_exists($updated, 'getUuid') ? $updated->getUuid() : null) === $objectId)); + + // Check if this object was skipped (no changes) + $unchangedObjects = $saveResult['unchanged'] ?? $saveResult['skipped'] ?? []; + $wasSkipped = !empty(array_filter($unchangedObjects, + fn($unchanged) => (is_object($unchanged) && method_exists($unchanged, 'getUuid') ? $unchanged->getUuid() : null) === $objectId)); + + // Check if this object had validation errors + $hasErrors = !empty(array_filter($saveResult['invalid'] ?? [], + fn($invalid) => (($invalid['object']['@self']['id'] ?? null) === $objectId))); + + if ($wasCreated) { + $statistics[$sectionKey]['created']++; + } elseif ($wasUpdated) { + $statistics[$sectionKey]['updated']++; + } elseif ($wasSkipped) { + $statistics[$sectionKey]['skipped']++; + } elseif ($hasErrors) { + // Add to errors array for this section + $errorInfo = array_filter($saveResult['invalid'] ?? [], + fn($invalid) => (($invalid['object']['@self']['id'] ?? null) === $objectId)); + + if (!empty($errorInfo)) { + $statistics[$sectionKey]['errors'][] = array_values($errorInfo)[0]['error'] ?? 'Unknown validation error'; + } + } else { + // This shouldn't happen, but leave as fallback + $statistics[$sectionKey]['skipped']++; + } + } + + // Calculate summary totals from actual statistics + $summary = [ + 'total_objects_created' => 0, + 'total_objects_updated' => 0, + 'total_objects_deleted' => 0, + 'total_objects_unchanged' => 0, + 'total_errors' => 0 + ]; + + foreach ($statistics as $section => $sectionStats) { + if ($section !== 'summary') { // Skip summary section itself + $summary['total_objects_created'] += $sectionStats['created']; + $summary['total_objects_updated'] += $sectionStats['updated']; + $summary['total_objects_unchanged'] += $sectionStats['skipped']; + $summary['total_errors'] += count($sectionStats['errors']); + } + } + + $statistics['summary'] = $summary; + } + + return $statistics; + } + + /** + * Get section structure configuration for XML parsing + * + * @param string $sectionName The name of the section (e.g., 'elements', 'relationships', 'views', etc.) + * @return array Configuration with direct_tags and nested_paths for finding items + */ + private function getSectionStructureConfig(string $sectionName): array + { + // Define the structure configuration for each section type + $configs = [ + 'elements' => [ + 'direct_tags' => ['element', 'elements'], + 'nested_paths' => [ + ['model', 'elements', 'element'], + ['model', 'elements'], + ['elements', 'element'], + ['elements'] + ] + ], + 'relationships' => [ + 'direct_tags' => ['relationship', 'relationships'], + 'nested_paths' => [ + ['model', 'relationships', 'relationship'], + ['model', 'relationships'], + ['relationships', 'relationship'], + ['relationships'] + ] + ], + 'views' => [ + 'direct_tags' => ['view', 'views', 'diagram', 'diagrams'], + 'nested_paths' => [ + ['model', 'views', 'diagrams', 'view'], + ['model', 'views', 'diagrams'], + ['model', 'views'], + ['views', 'diagrams', 'view'], + ['views', 'diagrams'], + ['views'] + ] + ], + 'organizations' => [ + 'direct_tags' => ['item', 'items'], + 'nested_paths' => [ + ['model', 'organizations', 'item'], + ['model', 'organizations'], + ['organizations', 'item'], + ['organizations'] + ] + ], + 'property_definitions' => [ + 'direct_tags' => ['propertyDefinition', 'propertyDefinitions'], + 'nested_paths' => [ + ['model', 'propertyDefinitions', 'propertyDefinition'], + ['model', 'propertyDefinitions'], + ['propertyDefinitions', 'propertyDefinition'], + ['propertyDefinitions'] + ] + ] + ]; + + return $configs[$sectionName] ?? [ + 'direct_tags' => [$sectionName], + 'nested_paths' => [[$sectionName]] + ]; + } + + /** + * Check if an array is associative (has string keys) + * + * @param array $array The array to check + * @return bool True if associative, false if indexed + */ + private function isAssociativeArray(array $array): bool + { + return count(array_filter(array_keys($array), 'is_string')) > 0; + } + + /** + * Find items within a specific section using AMEF configuration + * + * @param array $sectionData The section data to search + * @param string $sectionName The name of the section + * @return array Array of items found + */ + private function findItemsInSection(array $sectionData, string $sectionName): array + { + // OPTIMIZATION: Removed debug logging from section processing + + $items = []; + + // Safety check: ensure sectionData is an array + if (!is_array($sectionData)) { + return []; + } + + // Get section structure configuration from AMEF config + $config = $this->getSectionStructureConfig($sectionName); + + // Special handling for views with diagrams structure + if ($sectionName === 'views') { + + // Handle nested structure: + if (isset($sectionData['diagrams'])) { + if (isset($sectionData['diagrams']['view'])) { + $viewArray = $sectionData['diagrams']['view']; + + // Handle single view vs array of views + if (!isset($viewArray[0]) && isset($viewArray['_attributes'])) { + // Single view + $items = [$viewArray]; + } else { + // Array of views + $items = $viewArray; + } + } + } else { + // Direct views structure (fallback) + if (isset($sectionData['view'])) { + $items = $sectionData['view']; + } + } + } else { + // Try to find items using the configured paths for other sections + foreach ($config['nested_paths'] as $path) { + $currentData = $sectionData; + $pathValid = true; + + foreach ($path as $key) { + if (isset($currentData[$key])) { + $currentData = $currentData[$key]; + } else { + $pathValid = false; + break; + } + } + + if ($pathValid && is_array($currentData)) { + // Check if this is a direct array of items or needs further processing + if (isset($currentData[0]) || $this->isAssociativeArray($currentData)) { + $items = $currentData; + break; + } + } + } + } + + // If no items found through nested paths, try direct tags + if (empty($items)) { + foreach ($config['direct_tags'] as $tag) { + if (isset($sectionData[$tag])) { + $items = $sectionData[$tag]; + break; + } + } + } + + // If still no items found, treat the section itself as items + if (empty($items)) { + $items = [$sectionData]; + } + + // Ensure items is always an array + if (!is_array($items)) { + $items = [$items]; + } + + // If items is an associative array with numeric keys, convert to indexed array + if ($this->isAssociativeArray($items)) { + $items = array_values($items); + } + + return $items; + } + + /** + * Extract identifier from item data + * + * @param array $item Item data + * @param string $sectionName The section name for special handling + * @return string|null Identifier or null if not found + */ + private function extractIdentifier(array $item, string $sectionName = ''): ?string + { + // OPTIMIZATION: Use cached patterns for section-specific identifier extraction + if (isset($this->identifierPatternCache[$sectionName])) { + $patterns = $this->identifierPatternCache[$sectionName]; + + // Try cached patterns in order of success frequency + foreach ($patterns as $pattern) { + $result = $this->extractIdentifierByPattern($item, $pattern); + if ($result !== null) { + return $result; + } + } + } + + // OPTIMIZATION: Build pattern cache on first encounter of section type + $patterns = $this->buildIdentifierPatternsForSection($sectionName); + $this->identifierPatternCache[$sectionName] = $patterns; + + // Try all patterns and return first successful match + foreach ($patterns as $pattern) { + $result = $this->extractIdentifierByPattern($item, $pattern); + if ($result !== null) { + return $result; + } + } + + return null; + } + + /** + * OPTIMIZATION: Extract identifier using a specific pattern + * + * @param array $item The item to extract from + * @param array $pattern The extraction pattern ['path' => string[], 'type' => string] + * @return string|null The extracted identifier or null + */ + private function extractIdentifierByPattern(array $item, array $pattern): ?string + { + $path = $pattern['path']; + $type = $pattern['type']; + + // Navigate to the target location + $current = $item; + foreach ($path as $key) { + if (!isset($current[$key])) { + return null; + } + $current = $current[$key]; + } + + // Extract based on type + switch ($type) { + case 'direct': + return is_string($current) ? $current : null; + case 'value': + return is_array($current) && isset($current['_value']) ? (string) $current['_value'] : null; + case 'array_search': + if (is_array($current)) { + foreach ($current as $childItem) { + if (isset($childItem['_attributes']['identifierRef'])) { + return (string) $childItem['_attributes']['identifierRef']; + } + } + } + return null; + default: + return null; + } + } + + /** + * OPTIMIZATION: Build identifier extraction patterns for a section type + * + * @param string $sectionName The section name + * @return array Array of extraction patterns ordered by likelihood of success + */ + private function buildIdentifierPatternsForSection(string $sectionName): array + { + $patterns = []; + + // Special handling for organizations + if ($sectionName === 'organizations') { + $patterns[] = ['path' => ['_attributes', 'identifierRef'], 'type' => 'direct']; + $patterns[] = ['path' => ['item'], 'type' => 'array_search']; + $patterns[] = ['path' => ['label'], 'type' => 'value']; + $patterns[] = ['path' => ['label'], 'type' => 'direct']; + } else { + // Standard patterns for other sections (ordered by frequency in ArchiMate) + $patterns[] = ['path' => ['_attributes', 'identifier'], 'type' => 'direct']; + $patterns[] = ['path' => ['_attributes', 'id'], 'type' => 'direct']; + $patterns[] = ['path' => ['identifier'], 'type' => 'value']; + $patterns[] = ['path' => ['identifier'], 'type' => 'direct']; + $patterns[] = ['path' => ['id'], 'type' => 'value']; + $patterns[] = ['path' => ['id'], 'type' => 'direct']; + $patterns[] = ['path' => ['_attributes', 'name'], 'type' => 'direct']; + $patterns[] = ['path' => ['name'], 'type' => 'value']; + $patterns[] = ['path' => ['name'], 'type' => 'direct']; + } + + return $patterns; + } + + /** + * OPTIMIZATION: Extract only essential XML data to reduce memory usage by 20-30% + * + * Instead of storing the complete XML structure, this method extracts only + * the essential data needed for round-trip fidelity and export functionality. + * For view objects, element splicing is performed if elements lookup is provided. + * + * @param array $item The complete XML item data + * @param array $elementsLookup Optional elements lookup for view processing + * @param string $schemaType Schema type for conditional processing + * @return array Essential XML data for storage + */ + private function extractEssentialXmlData(array $item, array $elementsLookup = [], string $schemaType = ''): array + { + $essential = []; + + // Always preserve core attributes (needed for export) + if (isset($item['_attributes'])) { + $essential['_attributes'] = $item['_attributes']; + } + + // Preserve name and documentation (already extracted to root level but needed for export) + if (isset($item['name'])) { + $essential['name'] = $item['name']; + } + + if (isset($item['documentation'])) { + $essential['documentation'] = $item['documentation']; + } + + // Preserve properties structure (needed for property mapping) + if (isset($item['properties'])) { + $essential['properties'] = $item['properties']; + } + + // For relationships, preserve source/target information + if (isset($item['source'])) { + $essential['source'] = $item['source']; + } + + if (isset($item['target'])) { + $essential['target'] = $item['target']; + } + + // Preserve any other critical ArchiMate-specific fields + $criticalFields = ['type', 'viewpoint', 'accessType', 'isDirected']; + foreach ($criticalFields as $field) { + if (isset($item[$field])) { + $essential[$field] = $item[$field]; + } + } + + // Extract nodes and connections for view objects with element splicing + if ($schemaType === 'view') { + $this->extractViewNodesAndConnections($item, $essential, $elementsLookup); + } else { + $this->extractViewNodesAndConnections($item, $essential); + } + + // Add a marker to indicate this is essential data (for debugging) + $essential['_essential_data'] = true; + + return $essential; + } + + /** + * Extract view nodes and relationships for view objects with proper JSON structure + * + * This method extracts and transforms nodes and connections from view XML data into + * the standardized viewNodes and viewRelationships format used by the frontend. + * + * @param array $item The complete XML item data + * @param array &$essential Essential XML data to add viewNodes/viewRelationships to (by reference) + * @param array $elementsLookup Optional lookup array of elements by identifier for enrichment + * @return void + */ + private function extractViewNodesAndConnections(array $item, array &$essential, array $elementsLookup = []): void + { + // Only process if this looks like a view object (has nodes or connections) + if (!isset($item['node']) && !isset($item['connection'])) { + return; + } + + // Extract viewNodes array with proper JSON structure + if (isset($item['node'])) { + $essential['viewNodes'] = $this->extractViewNodesRecursively($item['node'], $elementsLookup); + } + + // Extract viewRelationships array with proper JSON structure + if (isset($item['connection'])) { + $essential['viewRelationships'] = $this->extractViewRelationshipsRecursively($item['connection']); + } + } + + /** + * Extract view nodes with proper JSON structure for frontend consumption + * + * This method transforms ArchiMate XML node data into the standardized viewNodes format + * expected by the frontend visualization components. + * + * **Parent-Child Relationship Handling:** + * - Root-level nodes have `parent: null` + * - Nested nodes have `parent: parentNodeId` set by the recursive processor + * - All nodes are flattened into a single array for efficient storage/querying + * - Frontend can reconstruct the hierarchy using parent references + * + * **Example Structure:** + * ``` + * [ + * { viewNodeId: 'node1', parent: null }, // Root node + * { viewNodeId: 'node2', parent: 'node1' }, // Child of node1 + * { viewNodeId: 'node3', parent: 'node1' }, // Child of node1 + * { viewNodeId: 'node4', parent: 'node2' } // Grandchild (child of node2) + * ] + * ``` + * + * @param array $nodeData Node data (can be single node or array of nodes) + * @param array $elementsLookup Lookup array of elements by identifier for enrichment + * @return array Array of viewNodes with standardized structure including parent references + */ + private function extractViewNodesRecursively($nodeData, array $elementsLookup = []): array + { + $viewNodes = []; + + // Handle both single node and array of nodes + if (!isset($nodeData[0])) { + // Single node + $nodeData = [$nodeData]; + } + + foreach ($nodeData as $node) { + if (!isset($node['_attributes'])) { + continue; + } + + $nodeId = $node['_attributes']['identifier'] ?? null; + $elementRef = $node['_attributes']['elementRef'] ?? null; + + if (!$nodeId) { + continue; + } + + // Create viewNode with standardized structure + $viewNode = [ + 'modelNodeId' => $elementRef, // Reference to the ArchiMate element + 'viewNodeId' => $nodeId, // Unique identifier within this view + 'x' => isset($node['_attributes']['x']) ? (int)$node['_attributes']['x'] : 0, + 'y' => isset($node['_attributes']['y']) ? (int)$node['_attributes']['y'] : 0, + 'width' => isset($node['_attributes']['w']) ? (int)$node['_attributes']['w'] : 100, + 'height' => isset($node['_attributes']['h']) ? (int)$node['_attributes']['h'] : 50, + 'parent' => null, // Will be set to parent nodeId for nested nodes (see recursive processing below) + 'name' => null, + 'type' => $this->extractNodeType($node), // Extract type directly from node XML + 'color' => 'rgb(255, 255, 255)', // Default white background + 'borderColor' => 'rgb(0, 0, 0)', // Default black border + 'font' => [ + 'name' => 'Segoe UI, Arial', + 'size' => 12, + 'style' => 'normal', + 'color' => 'rgb(0, 0, 0)' + ], + 'description' => null, + 'elementRef' => $elementRef + ]; + + // Extract style information if present + if (isset($node['style'])) { + $this->applyNodeStyle($viewNode, $node['style']); + } + + // Extract label text for Label type nodes + if (isset($node['label'])) { + if (is_array($node['label']) && isset($node['label']['_value'])) { + $viewNode['name'] = $node['label']['_value']; + } elseif (is_string($node['label'])) { + $viewNode['name'] = $node['label']; + } + } + + // Enrich with element data if available and elementRef exists + if ($elementRef && isset($elementsLookup[$elementRef])) { + $element = $elementsLookup[$elementRef]; + + // Use element name if node doesn't have its own label + if ($viewNode['name'] === null && isset($element['name'])) { + $viewNode['name'] = $element['name']; + } + + // Set description from element documentation + if (isset($element['summary'])) { + $viewNode['description'] = $element['summary']; + } elseif (isset($element['documentation'])) { + $viewNode['description'] = $element['documentation']; + } + + // Enhance type with element data if node type wasn't fully extracted + if ($viewNode['type'] === null || $viewNode['type'] === 'element') { + if (isset($element['gemmaType'])) { + $gemmaType = $element['gemmaType']; + // Handle case where gemmaType might be an array + if (is_array($gemmaType)) { + $gemmaType = $gemmaType['_value'] ?? $gemmaType[0] ?? 'unknown'; + } + $viewNode['type'] = strtolower((string)$gemmaType); + } elseif (isset($element['xml']['_attributes']['xsi:type'])) { + $archiType = $element['xml']['_attributes']['xsi:type']; + // Convert ArchiMate type to simplified type (e.g., "archimate:BusinessService" -> "businessservice") + $viewNode['type'] = strtolower(preg_replace('/^archimate:|^[a-z]+:/', '', $archiType)); + } + } + + // Add all element properties to view node for full data access + $viewNode['elementProperties'] = $this->extractElementProperties($element); + + // Add specific useful properties directly to the node + if (isset($element['objectId'])) { + $viewNode['objectId'] = $element['objectId']; + } + + // Add GEMMA-specific properties if they exist + $gemmaProperties = ['gemmaType', 'bivScoreBbn', 'belangrijksteReden', 'beschikbaarheid', 'integriteit', 'vertrouwelijkheid']; + foreach ($gemmaProperties as $prop) { + if (isset($element[$prop])) { + $viewNode[$prop] = $element[$prop]; + } + } + + // Store element section for reference + if (isset($element['section'])) { + $viewNode['elementSection'] = $element['section']; + } + + // Store model identifier for linking + if (isset($element['model_identifier'])) { + $viewNode['modelIdentifier'] = $element['model_identifier']; + } + } + + // Handle child nodes recursively (flatten hierarchy into single array while preserving parent-child relationships) + if (isset($node['node'])) { + $childNodes = $this->extractViewNodesRecursively($node['node'], $elementsLookup); + + // IMPORTANT: Set parent reference for all child nodes to maintain hierarchy + // This allows the frontend to reconstruct the nested structure + foreach ($childNodes as &$childNode) { + $childNode['parent'] = $nodeId; // Parent is the current node's ID + } + unset($childNode); + + // Add child nodes to the main flattened array (maintaining parent references) + $viewNodes = array_merge($viewNodes, $childNodes); + } + + $viewNodes[] = $viewNode; + } + + return $viewNodes; + } + + /** + * Debug helper: Log parent-child relationships in view nodes + * + * @param array $viewNodes Array of view nodes with parent references + * @param string $viewId View identifier for logging context + * @return void + */ + private function debugViewNodeHierarchy(array $viewNodes, string $viewId): void + { + $rootNodes = array_filter($viewNodes, fn($node) => $node['parent'] === null); + $childNodes = array_filter($viewNodes, fn($node) => $node['parent'] !== null); + + $this->logger->debug("View node hierarchy for view: {$viewId}", [ + 'total_nodes' => count($viewNodes), + 'root_nodes' => count($rootNodes), + 'child_nodes' => count($childNodes), + 'parent_child_pairs' => array_map( + fn($node) => ['child' => $node['viewNodeId'], 'parent' => $node['parent']], + $childNodes + ) + ]); + } + + /** + * Recursively extract nested nodes with full hierarchy and element splicing (LEGACY - for backward compatibility) + * + * This method processes nodes and their children recursively to capture the complete + * nested structure as it appears in the ArchiMate XML. When a node references an element + * via elementRef, the actual element data (minus _xml) is spliced into the node's + * 'element' property. + * + * @param array $nodeData Node data (can be single node or array of nodes) + * @param array $elementsLookup Lookup array of elements by identifier for splicing + * @return array Array of processed nodes with nested children and spliced elements + */ + private function extractNodesRecursively($nodeData, array $elementsLookup = []): array + { + $nodes = []; + + // Handle both single node and array of nodes + if (!isset($nodeData[0])) { + // Single node + $nodeData = [$nodeData]; + } + + foreach ($nodeData as $node) { + if (isset($node['_attributes'])) { + $processedNode = [ + 'identifier' => $node['_attributes']['identifier'] ?? null, + 'elementRef' => $node['_attributes']['elementRef'] ?? null, + 'type' => $node['_attributes']['xsi:type'] ?? 'Element', + 'x' => isset($node['_attributes']['x']) ? (int)$node['_attributes']['x'] : null, + 'y' => isset($node['_attributes']['y']) ? (int)$node['_attributes']['y'] : null, + 'w' => isset($node['_attributes']['w']) ? (int)$node['_attributes']['w'] : null, + 'h' => isset($node['_attributes']['h']) ? (int)$node['_attributes']['h'] : null + ]; + + // Extract style information if present + if (isset($node['style'])) { + $processedNode['style'] = $this->extractNodeStyle($node['style']); + } + + // Extract label text for Label type nodes + if (isset($node['label'])) { + if (is_array($node['label']) && isset($node['label']['_value'])) { + $processedNode['label'] = $node['label']['_value']; + } elseif (is_string($node['label'])) { + $processedNode['label'] = $node['label']; + } + } + + // ELEMENT SPLICING: If node references an element, splice it in + if (!empty($processedNode['elementRef']) && !empty($elementsLookup)) { + $elementRef = $processedNode['elementRef']; + if (isset($elementsLookup[$elementRef])) { + // Splice element data (minus _xml and other metadata) into the node + $element = $elementsLookup[$elementRef]; + $processedNode['element'] = $this->prepareElementForSplicing($element); + } + } + + // RECURSIVE: Extract child nodes if they exist (with element splicing) + if (isset($node['node'])) { + $processedNode['children'] = $this->extractNodesRecursively($node['node'], $elementsLookup); + } + + // RECURSIVE: Extract child connections if they exist + if (isset($node['connection'])) { + $processedNode['connections'] = $this->extractConnectionsRecursively($node['connection']); + } + + $nodes[] = $processedNode; + } + } + + return $nodes; + } + + /** + * Prepare element data for splicing by removing internal metadata + * + * @param array $element The complete element object + * @return array Element data suitable for splicing (without _xml, @self, etc.) + */ + private function prepareElementForSplicing(array $element): array + { + // Start with a copy of the element + $splicedElement = $element; + + // Remove internal metadata fields that shouldn't be in spliced data + $fieldsToRemove = ['@self', 'xml', '_xml', 'section', 'model_identifier', 'extracted_at', '_propertyMapping']; + + foreach ($fieldsToRemove as $field) { + unset($splicedElement[$field]); + } + + return $splicedElement; + } + + /** + * Extract connections recursively + * + * @param array $connectionData Connection data (can be single connection or array) + * @return array Array of processed connections + */ + private function extractConnectionsRecursively($connectionData): array + { + $connections = []; + + // Handle both single connection and array of connections + if (!isset($connectionData[0])) { + // Single connection + $connectionData = [$connectionData]; + } + + foreach ($connectionData as $connection) { + if (isset($connection['_attributes'])) { + $processedConnection = [ + 'identifier' => $connection['_attributes']['identifier'] ?? null, + 'relationshipRef' => $connection['_attributes']['relationshipRef'] ?? null, + 'type' => $connection['_attributes']['xsi:type'] ?? 'Relationship', + 'source' => $connection['_attributes']['source'] ?? null, + 'target' => $connection['_attributes']['target'] ?? null + ]; + + // Extract style information if present + if (isset($connection['style'])) { + $processedConnection['style'] = $this->extractConnectionStyle($connection['style']); + } + + $connections[] = $processedConnection; + } + } + + return $connections; + } + + /** + * Extract all properties from an element for view node enrichment + * + * @param array $element Element data containing properties + * @return array Clean array of element properties + */ + private function extractElementProperties(array $element): array + { + $properties = []; + + // Extract basic element properties + $basicProperties = ['identifier', 'name', 'summary', 'section', 'model_identifier']; + foreach ($basicProperties as $prop) { + if (isset($element[$prop])) { + $properties[$prop] = $element[$prop]; + } + } + + // Extract all flattened properties (camelCase converted properties) + $excludedKeys = ['@self', 'xml', '_propertyMapping', '_slug', '_essential_data']; + foreach ($element as $key => $value) { + if (!in_array($key, $excludedKeys) && !in_array($key, $basicProperties)) { + // Only include non-object values or simple arrays + if (is_scalar($value) || (is_array($value) && !$this->isComplexArray($value))) { + $properties[$key] = $value; + } + } + } + + // Add property mapping for reference if available + if (isset($element['_propertyMapping'])) { + $properties['_propertyMapping'] = $element['_propertyMapping']; + } + + return $properties; + } + + /** + * Check if an array contains complex nested structures + * + * @param array $array Array to check + * @return bool True if array contains complex nested structures + */ + private function isComplexArray(array $array): bool + { + foreach ($array as $value) { + if (is_array($value) || is_object($value)) { + return true; + } + } + return false; + } + + /** + * Apply style information to a viewNode structure + * + * @param array &$viewNode ViewNode structure to apply styles to (by reference) + * @param array $style Style data from XML + * @return void + */ + private function applyNodeStyle(array &$viewNode, array $style): void + { + // Extract fillColor + if (isset($style['fillColor']['_attributes'])) { + $fillColor = $style['fillColor']['_attributes']; + $r = isset($fillColor['r']) ? (int)$fillColor['r'] : 255; + $g = isset($fillColor['g']) ? (int)$fillColor['g'] : 255; + $b = isset($fillColor['b']) ? (int)$fillColor['b'] : 255; + $viewNode['color'] = "rgb($r, $g, $b)"; + } + + // Extract lineColor + if (isset($style['lineColor']['_attributes'])) { + $lineColor = $style['lineColor']['_attributes']; + $r = isset($lineColor['r']) ? (int)$lineColor['r'] : 0; + $g = isset($lineColor['g']) ? (int)$lineColor['g'] : 0; + $b = isset($lineColor['b']) ? (int)$lineColor['b'] : 0; + $viewNode['borderColor'] = "rgb($r, $g, $b)"; + } + + // Extract font information + if (isset($style['font'])) { + $font = []; + if (isset($style['font']['_attributes'])) { + $font['name'] = $style['font']['_attributes']['name'] ?? 'Segoe UI, Arial'; + $font['size'] = isset($style['font']['_attributes']['size']) ? (int)$style['font']['_attributes']['size'] : 12; + $font['style'] = 'normal'; + } + + if (isset($style['font']['color']['_attributes'])) { + $fontColor = $style['font']['color']['_attributes']; + $r = isset($fontColor['r']) ? (int)$fontColor['r'] : 0; + $g = isset($fontColor['g']) ? (int)$fontColor['g'] : 0; + $b = isset($fontColor['b']) ? (int)$fontColor['b'] : 0; + $font['color'] = "rgb($r, $g, $b)"; + } + + if (!empty($font)) { + $viewNode['font'] = array_merge($viewNode['font'], $font); + } + } + } + + /** + * Extract view relationships with proper JSON structure for frontend consumption + * + * This method transforms ArchiMate XML connection data into the standardized viewRelationships + * format expected by the frontend visualization components. + * + * @param array $connectionData Connection data (can be single connection or array) + * @return array Array of viewRelationships with standardized structure + */ + private function extractViewRelationshipsRecursively($connectionData): array + { + $viewRelationships = []; + + // Handle both single connection and array of connections + if (!isset($connectionData[0])) { + // Single connection + $connectionData = [$connectionData]; + } + + foreach ($connectionData as $connection) { + if (!isset($connection['_attributes'])) { + continue; + } + + $connectionId = $connection['_attributes']['identifier'] ?? null; + $relationshipRef = $connection['_attributes']['relationshipRef'] ?? null; + $source = $connection['_attributes']['source'] ?? null; + $target = $connection['_attributes']['target'] ?? null; + + if (!$connectionId || !$source || !$target) { + continue; + } + + // Create viewRelationship with standardized structure + $viewRelationship = [ + 'modelRelationshipId' => $relationshipRef, // Reference to the ArchiMate relationship + 'sourceId' => $source, // Source node viewNodeId + 'targetId' => $target, // Target node viewNodeId + 'viewRelationshipId' => $connectionId, // Unique identifier within this view + 'type' => $this->extractConnectionType($connection), // Extract type directly from connection XML + 'bendpoints' => [], // Array of bend points + 'label' => [] // Label information + ]; + + // Extract bend points if present + if (isset($connection['bendpoint'])) { + $bendpoints = isset($connection['bendpoint'][0]) ? $connection['bendpoint'] : [$connection['bendpoint']]; + + foreach ($bendpoints as $bendpoint) { + if (isset($bendpoint['_attributes'])) { + $viewRelationship['bendpoints'][] = [ + 'x' => isset($bendpoint['_attributes']['x']) ? (float)$bendpoint['_attributes']['x'] : 0, + 'y' => isset($bendpoint['_attributes']['y']) ? (float)$bendpoint['_attributes']['y'] : 0 + ]; + } + } + } + + // Extract label information if present + if (isset($connection['label'])) { + $label = []; + + if (is_array($connection['label']) && isset($connection['label']['_value'])) { + $label['text'] = $connection['label']['_value']; + } elseif (is_string($connection['label'])) { + $label['text'] = $connection['label']; + } + + // Extract label markup/style if present + if (isset($connection['style'])) { + $label['markup'] = [$this->extractLabelMarkup($connection['style'])]; + } + + $viewRelationship['label'] = $label; + } + + $viewRelationships[] = $viewRelationship; + } + + return $viewRelationships; + } + + /** + * Extract label markup information from connection style + * + * @param array $style Style data from XML + * @return array Label markup structure + */ + private function extractLabelMarkup(array $style): array + { + $markup = [ + 'style' => [ + 'fontSize' => 11, + 'fontFamily' => 'Segoe UI, Arial', + 'fontColor' => 'rgba(0, 0, 0, 1)', + 'fontStyle' => 'normal', + 'fontWeight' => 'normal' + ] + ]; + + // Extract font information if present + if (isset($style['font'])) { + if (isset($style['font']['_attributes'])) { + $markup['style']['fontFamily'] = $style['font']['_attributes']['name'] ?? 'Segoe UI, Arial'; + $markup['style']['fontSize'] = isset($style['font']['_attributes']['size']) ? (int)$style['font']['_attributes']['size'] : 11; + } + + if (isset($style['font']['color']['_attributes'])) { + $fontColor = $style['font']['color']['_attributes']; + $r = isset($fontColor['r']) ? (int)$fontColor['r'] : 0; + $g = isset($fontColor['g']) ? (int)$fontColor['g'] : 0; + $b = isset($fontColor['b']) ? (int)$fontColor['b'] : 0; + $a = isset($fontColor['a']) ? ((int)$fontColor['a'] / 100) : 1; // Convert percentage to decimal + $markup['style']['fontColor'] = "rgba($r, $g, $b, $a)"; + } + } + + return $markup; + } + + /** + * Extract node type directly from node XML attributes + * + * @param array $node Node data from XML + * @return string|null Node type extracted from XML or null if not found + */ + private function extractNodeType(array $node): ?string + { + // Priority 1: Check xsi:type attribute (most specific) + if (isset($node['_attributes']['xsi:type'])) { + $xsiType = $node['_attributes']['xsi:type']; + + // Handle different xsi:type formats + if ($xsiType === 'Label') { + return 'label'; + } elseif ($xsiType === 'Element') { + return 'element'; + } elseif (str_contains($xsiType, ':')) { + // Handle namespaced types like "archimate:BusinessService" + return strtolower(preg_replace('/^[a-z]+:/', '', $xsiType)); + } else { + return strtolower($xsiType); + } + } + + // Priority 2: Check if this is a Label node (has label content) + if (isset($node['label'])) { + return 'label'; + } + + // Priority 3: Check if this has an elementRef (it's an Element node) + if (isset($node['_attributes']['elementRef'])) { + return 'element'; + } + + // Fallback: Return null to allow element lookup to fill in the type + return null; + } + + /** + * Extract connection type directly from connection XML attributes + * + * @param array $connection Connection data from XML + * @return string Connection type extracted from XML or default 'association' + */ + private function extractConnectionType(array $connection): string + { + // Priority 1: Check xsi:type attribute (most specific) + if (isset($connection['_attributes']['xsi:type'])) { + $xsiType = $connection['_attributes']['xsi:type']; + + // Handle different connection type formats + if (str_contains($xsiType, 'Relationship')) { + // Remove "Relationship" suffix and namespace prefix + // e.g., "archimate:ServingRelationship" -> "serving" + $type = preg_replace('/^[a-z]+:/', '', $xsiType); // Remove namespace + $type = preg_replace('/relationship$/i', '', $type); // Remove "Relationship" suffix + return strtolower($type); + } elseif (str_contains($xsiType, ':')) { + // Handle other namespaced types + return strtolower(preg_replace('/^[a-z]+:/', '', $xsiType)); + } else { + return strtolower($xsiType); + } + } + + // Priority 2: Check if this has a relationshipRef (use that to determine type if possible) + if (isset($connection['_attributes']['relationshipRef'])) { + // We have a relationship reference, but we can't determine the type from just the ID + // Return generic 'relationship' type + return 'relationship'; + } + + // Fallback: Default association type + return 'association'; + } + + /** + * Extract style information from a node (LEGACY - for backward compatibility) + * + * @param array $style Style data from XML + * @return array Processed style information + */ + private function extractNodeStyle(array $style): array + { + $processedStyle = []; + + // Extract fillColor + if (isset($style['fillColor']['_attributes'])) { + $fillColor = $style['fillColor']['_attributes']; + $processedStyle['fillColor'] = [ + 'r' => isset($fillColor['r']) ? (int)$fillColor['r'] : 255, + 'g' => isset($fillColor['g']) ? (int)$fillColor['g'] : 255, + 'b' => isset($fillColor['b']) ? (int)$fillColor['b'] : 255, + 'a' => isset($fillColor['a']) ? (int)$fillColor['a'] : 100 + ]; + } + + // Extract lineColor + if (isset($style['lineColor']['_attributes'])) { + $lineColor = $style['lineColor']['_attributes']; + $processedStyle['lineColor'] = [ + 'r' => isset($lineColor['r']) ? (int)$lineColor['r'] : 0, + 'g' => isset($lineColor['g']) ? (int)$lineColor['g'] : 0, + 'b' => isset($lineColor['b']) ? (int)$lineColor['b'] : 0, + 'a' => isset($lineColor['a']) ? (int)$lineColor['a'] : 100 + ]; + } + + // Extract font information + if (isset($style['font'])) { + $font = []; + if (isset($style['font']['_attributes'])) { + $font['name'] = $style['font']['_attributes']['name'] ?? 'Arial'; + $font['size'] = isset($style['font']['_attributes']['size']) ? (int)$style['font']['_attributes']['size'] : 12; + } + + if (isset($style['font']['color']['_attributes'])) { + $fontColor = $style['font']['color']['_attributes']; + $font['color'] = [ + 'r' => isset($fontColor['r']) ? (int)$fontColor['r'] : 0, + 'g' => isset($fontColor['g']) ? (int)$fontColor['g'] : 0, + 'b' => isset($fontColor['b']) ? (int)$fontColor['b'] : 0 + ]; + } + + if (!empty($font)) { + $processedStyle['font'] = $font; + } + } + + return $processedStyle; + } + + /** + * Extract style information from a connection + * + * @param array $style Style data from XML + * @return array Processed style information + */ + private function extractConnectionStyle(array $style): array + { + $processedStyle = []; + + // Extract lineColor + if (isset($style['lineColor']['_attributes'])) { + $lineColor = $style['lineColor']['_attributes']; + $processedStyle['lineColor'] = [ + 'r' => isset($lineColor['r']) ? (int)$lineColor['r'] : 0, + 'g' => isset($lineColor['g']) ? (int)$lineColor['g'] : 0, + 'b' => isset($lineColor['b']) ? (int)$lineColor['b'] : 0 + ]; + } + + // Extract font information + if (isset($style['font'])) { + $font = []; + if (isset($style['font']['_attributes'])) { + $font['name'] = $style['font']['_attributes']['name'] ?? 'Arial'; + $font['size'] = isset($style['font']['_attributes']['size']) ? (int)$style['font']['_attributes']['size'] : 12; + } + + if (isset($style['font']['color']['_attributes'])) { + $fontColor = $style['font']['color']['_attributes']; + $font['color'] = [ + 'r' => isset($fontColor['r']) ? (int)$fontColor['r'] : 0, + 'g' => isset($fontColor['g']) ? (int)$fontColor['g'] : 0, + 'b' => isset($fontColor['b']) ? (int)$fontColor['b'] : 0 + ]; + } + + if (!empty($font)) { + $processedStyle['font'] = $font; + } + } + + return $processedStyle; + } + + /** + * Extract GEMMA type from an object using multiple possible property names + * + * This method tries different variations of GEMMA type property names to ensure + * compatibility with different ArchiMate model variations. + * + * @param array $object The object to extract GEMMA type from + * @return string|null The GEMMA type value or null if not found + */ + private function extractGemmaType(array $object): ?string + { + // Try various possible property names for GEMMA type + $possiblePropertyNames = [ + 'gemmaType', // Standard camelCase conversion of "GEMMA Type" + 'gemmatype', // Lowercase version + 'GemmaType', // PascalCase version + 'GEMMA_Type', // Underscore version + 'gemma_type', // Lowercase underscore version + 'GEMMAType', // All caps first word + 'type', // Sometimes just "Type" in models + 'elementType', // Alternative naming + 'componentType' // Another alternative + ]; + + foreach ($possiblePropertyNames as $propertyName) { + if (isset($object[$propertyName]) && !empty($object[$propertyName])) { + $value = (string) $object[$propertyName]; + + // Log the first successful match for debugging + if (!isset($this->gemmaTypePropertyFound)) { + $this->logger->debug('GEMMA Type property found', [ + 'property_name' => $propertyName, + 'value' => $value, + 'object_id' => $object['identifier'] ?? 'unknown' + ]); + $this->gemmaTypePropertyFound = true; + } + + return $value; + } + } + + // If no direct property found, check _propertyMapping for original property names + if (isset($object['_propertyMapping'])) { + foreach ($object['_propertyMapping'] as $camelCase => $original) { + // Check if the original property name contains "gemma" or "type" + if (stripos($original, 'gemma') !== false && stripos($original, 'type') !== false) { + if (isset($object[$camelCase]) && !empty($object[$camelCase])) { + $this->logger->debug('GEMMA Type found via property mapping', [ + 'camel_case_name' => $camelCase, + 'original_name' => $original, + 'value' => $object[$camelCase], + 'object_id' => $object['identifier'] ?? 'unknown' + ]); + return (string) $object[$camelCase]; + } + } + } + } + + return null; + } + + /** + * Process GEMMA Referentiecomponent-Standaard relationships with Verbindingsrol support + * + * This method analyzes all objects to find Referentiecomponenten and Standaarden, + * then uses relationships to link them together based on Verbindingsrol property. + * Each Referentiecomponent gets two properties: + * - 'aanbevolenStandaarden' array for standards with Verbindingsrol = "Aanbevolen" + * - 'verplichteStandaarden' array for standards with Verbindingsrol = "Verplicht" + * + * @param array $objects All objects from the import + * @return array Objects with enhanced Referentiecomponent data + */ + private function processGemmaReferenceComponentStandards(array $objects): array + { + $this->logger->info('Processing GEMMA Referentiecomponent-Standaard relationships with optimized single-pass algorithm'); + + // OPTIMIZATION: Single-pass processing - collect all data types at once + $referentieComponenten = []; + $standaarden = []; + $gemmaRelationshipMap = []; + + // Debug: Count objects and property variations + $elementCount = 0; + $elementsWithGemmaType = 0; + $gemmaTypeVariations = []; + + // PASS 1: Collect Referentiecomponenten and Standaarden, process relationships immediately + foreach ($objects as $index => $object) { + // Debug: Count elements and GEMMA types + if (isset($object['section']) && $object['section'] === 'element') { + $elementCount++; + + // Check for various possible GEMMA type property names + $gemmaTypeValue = $this->extractGemmaType($object); + if ($gemmaTypeValue !== null) { + $elementsWithGemmaType++; + + // Track GEMMA type variations for debugging + if (!isset($gemmaTypeVariations[$gemmaTypeValue])) { + $gemmaTypeVariations[$gemmaTypeValue] = 0; + } + $gemmaTypeVariations[$gemmaTypeValue]++; + + if ($gemmaTypeValue === 'Referentiecomponent') { + $referentieComponenten[$object['identifier']] = $index; + } elseif ($gemmaTypeValue === 'Standaard') { + $standaarden[$object['identifier']] = $index; + } + } + } + + // Process relationships immediately when found (no separate collection needed) + if (isset($object['section']) && $object['section'] === 'relationship') { + $this->processRelationshipImmediate($object, $referentieComponenten, $standaarden, $gemmaRelationshipMap); + } + } + + // Enhanced debug logging + $this->logger->info('GEMMA objects processing complete', [ + 'total_elements' => $elementCount, + 'elements_with_gemma_type' => $elementsWithGemmaType, + 'gemma_type_variations' => $gemmaTypeVariations, + 'referentiecomponenten_count' => count($referentieComponenten), + 'standaarden_count' => count($standaarden), + 'processed_relationships' => count($gemmaRelationshipMap) + ]); + + // STEP 2: Apply the processed relationship mappings to Referentiecomponenten + $enhancedCount = 0; + foreach ($gemmaRelationshipMap as $referentieComponentId => $standaardenMap) { + if (isset($referentieComponenten[$referentieComponentId])) { + $objectIndex = $referentieComponenten[$referentieComponentId]; + + // Remove duplicates and add the properties + $aanbevolenStandaarden = array_unique($standaardenMap['aanbevolen']); + $verplichteStandaarden = array_unique($standaardenMap['verplicht']); + + $objects[$objectIndex]['aanbevolenStandaarden'] = $aanbevolenStandaarden; + $objects[$objectIndex]['verplichteStandaarden'] = $verplichteStandaarden; + + // Also add combined array for backward compatibility + $allStandaarden = array_unique(array_merge($aanbevolenStandaarden, $verplichteStandaarden)); + $objects[$objectIndex]['standaarden'] = $allStandaarden; + + $this->logger->info('Enhanced Referentiecomponent with categorized standaarden', [ + 'referentiecomponent_id' => $referentieComponentId, + 'referentiecomponent_name' => $objects[$objectIndex]['name'] ?? 'Unknown', + 'aanbevolen_count' => count($aanbevolenStandaarden), + 'verplicht_count' => count($verplichteStandaarden), + 'aanbevolen_ids' => $aanbevolenStandaarden, + 'verplicht_ids' => $verplichteStandaarden + ]); + + $enhancedCount++; + } + } + + $this->logger->info('GEMMA Referentiecomponent-Standaard processing completed', [ + 'referentiecomponenten_enhanced' => $enhancedCount, + 'total_referentiecomponenten' => count($referentieComponenten), + 'total_relationships_processed' => count($gemmaRelationshipMap) + ]); + + return $objects; + } + + /** + * OPTIMIZATION: Process relationship immediately when found (single-pass algorithm) + * + * @param array $relationship The relationship object + * @param array $referentieComponenten Array of Referentiecomponent identifiers + * @param array $standaarden Array of Standaard identifiers + * @param array &$gemmaRelationshipMap The relationship map to update (by reference) + * @return void + */ + private function processRelationshipImmediate(array $relationship, array $referentieComponenten, array $standaarden, array &$gemmaRelationshipMap): void + { + // Get source and target from relationship XML or flattened properties + $source = $this->extractRelationshipEndpoint($relationship, 'source'); + $target = $this->extractRelationshipEndpoint($relationship, 'target'); + + if (!$source || !$target) { + return; + } + + // Get Verbindingsrol from flattened properties (camelCase: verbindingsrol) + $verbindingsrol = $relationship['verbindingsrol'] ?? null; + + // Skip if no Verbindingsrol is defined + if (!$verbindingsrol) { + return; + } + + // Check if one end is a Referentiecomponent and the other is a Standaard + $refCompId = null; + $standaardId = null; + + if (isset($referentieComponenten[$source]) && isset($standaarden[$target])) { + // Referentiecomponent -> Standaard + $refCompId = $source; + $standaardId = $target; + } elseif (isset($standaarden[$source]) && isset($referentieComponenten[$target])) { + // Standaard -> Referentiecomponent (reverse direction) + $refCompId = $target; + $standaardId = $source; + } + + if ($refCompId && $standaardId) { + // Initialize arrays if not exists + if (!isset($gemmaRelationshipMap[$refCompId])) { + $gemmaRelationshipMap[$refCompId] = [ + 'aanbevolen' => [], + 'verplicht' => [] + ]; + } + + // Add to appropriate array based on Verbindingsrol + if (strtolower($verbindingsrol) === 'aanbevolen') { + $gemmaRelationshipMap[$refCompId]['aanbevolen'][] = $standaardId; + } elseif (strtolower($verbindingsrol) === 'verplicht') { + $gemmaRelationshipMap[$refCompId]['verplicht'][] = $standaardId; + } + } + } + + /** + * Extract relationship endpoint (source or target) from relationship object + * + * @param array $relationship The relationship object + * @param string $endpoint Either 'source' or 'target' + * @return string|null The endpoint identifier or null if not found + */ + private function extractRelationshipEndpoint(array $relationship, string $endpoint): ?string + { + // Try flattened camelCase property first + if (isset($relationship[$endpoint])) { + return $relationship[$endpoint]; + } + + // Try XML structure + if (isset($relationship['xml'][$endpoint])) { + $endpointData = $relationship['xml'][$endpoint]; + + // Handle different XML structures + if (is_string($endpointData)) { + return $endpointData; + } elseif (is_array($endpointData)) { + // Try _attributes.href or _value + if (isset($endpointData['_attributes']['href'])) { + return $endpointData['_attributes']['href']; + } elseif (isset($endpointData['_value'])) { + return $endpointData['_value']; + } + } + } + + // Try direct XML access for ArchiMate format + if (isset($relationship['xml']['_attributes'])) { + $attr = $relationship['xml']['_attributes']; + if ($endpoint === 'source' && isset($attr['source'])) { + return $attr['source']; + } elseif ($endpoint === 'target' && isset($attr['target'])) { + return $attr['target']; + } + } + + return null; + } + + /** + * SPEED OPTIMIZED: Transform ArchiMate XML data with maximum performance focus + * + * This implementation prioritizes speed over memory usage: + * 1. Pre-build ALL lookups in memory (elements, relationships, organizations) + * 2. Cache all parsing results and property mappings + * 3. Use bulk operations with memory buffers + * 4. Eliminate redundant operations through aggressive caching + * 5. Process everything in memory-intensive but fast data structures + * + * @param array $xmlData Parsed XML data + * @param string $modelIdentifier Model identifier + * @return array Array of objects ready for saveObjects() + */ + private function transformArchiMateXmlToObjectsBatch(array $xmlData, string $modelIdentifier): array + { + $startTime = microtime(true); + $allObjects = []; + + // SPEED OPTIMIZATION 1: Pre-extract and cache EVERYTHING + $cacheStartTime = microtime(true); + $propertyDefinitionMap = $this->extractPropertyDefinitionMap($xmlData); + + // Create model object first + if (isset($xmlData['_attributes']) || isset($xmlData['name'])) { + $modelMetadata = [ + 'identifier' => $modelIdentifier, + 'name' => $xmlData['name'] ?? '', + 'documentation' => $xmlData['documentation'] ?? '', + 'properties' => $xmlData['properties'] ?? [], + 'propertyDefinitionMap' => $propertyDefinitionMap + ]; + + if (isset($xmlData['_attributes'])) { + $modelMetadata = array_merge($modelMetadata, $xmlData['_attributes']); + } + + $allObjects[] = $this->createModelObjectDirect($modelMetadata, $modelIdentifier); + } + + // Process each section type directly (no intermediate normalization) + $sections = [ + 'elements' => 'element', + 'relationships' => 'relationship', + 'organizations' => 'organization', + 'views' => 'view', + 'property_definitions' => 'property_definition' + ]; + + // SPEED OPTIMIZATION 2: Pre-build ALL section lookups simultaneously + $allLookups = $this->buildAllLookupsSimultaneously($xmlData); + $elementsLookup = $this->buildElementsLookup($allObjects); // Will be rebuilt from processed objects + + $cacheTime = microtime(true) - $cacheStartTime; + $this->logger->info('Pre-built all lookups', [ + 'cache_build_time' => round($cacheTime, 3), + 'elements_count' => count($allLookups['elements']), + 'relationships_count' => count($allLookups['relationships']), + 'organizations_count' => count($allLookups['organizations']), + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 1) + ]); + + // SPEED OPTIMIZATION 3: Process all non-view sections in bulk + $bulkProcessingStart = microtime(true); + $nonViewObjects = $this->bulkProcessNonViewSections( + $xmlData, + $modelIdentifier, + $propertyDefinitionMap, + $allLookups + ); + $allObjects = array_merge($allObjects, $nonViewObjects); + + // SPEED OPTIMIZATION: Build elements lookup directly from raw data (faster than from processed objects) + $elementsLookup = $this->buildElementsLookupFromRawData($allLookups['elements'], $nonViewObjects, $propertyDefinitionMap); + + $bulkTime = microtime(true) - $bulkProcessingStart; + + // SPEED OPTIMIZATION 4: Process views with maximum speed optimizations + $viewProcessingStart = microtime(true); + $viewObjects = $this->processViewsMaximumSpeed( + $xmlData, + $modelIdentifier, + $propertyDefinitionMap, + $elementsLookup + ); + $allObjects = array_merge($allObjects, $viewObjects); + $viewTime = microtime(true) - $viewProcessingStart; + + $totalTime = microtime(true) - $startTime; + // Transformation completed + + // MEMORY CLEANUP: Free all intermediate lookups and caches before database operations + $memoryBeforeCleanup = memory_get_usage(true); + unset($allLookups, $elementsLookup, $propertyDefinitionMap); + $this->camelCaseCache = []; // Clear property name cache + $this->identifierPatternCache = []; // Clear identifier pattern cache + $this->propertyDefinitionMapCache = null; // Clear property definition cache + + // Force garbage collection to free memory immediately + if (function_exists('gc_collect_cycles')) { + $cycles = gc_collect_cycles(); + $memoryAfterCleanup = memory_get_usage(true); + $memoryFreed = $memoryBeforeCleanup - $memoryAfterCleanup; + + $this->logger->info('Memory cleanup before database operations', [ + 'memory_freed_mb' => round($memoryFreed / 1024 / 1024, 1), + 'gc_cycles_collected' => $cycles, + 'memory_before_mb' => round($memoryBeforeCleanup / 1024 / 1024, 1), + 'memory_after_mb' => round($memoryAfterCleanup / 1024 / 1024, 1) + ]); + } + + return $allObjects; + } + + /** + * Transform views with performance optimizations + * + * This method processes views with several optimizations: + * - Reduced memory allocations + * - Optimized element lookup caching + * - Streamlined recursive processing + * + * @param array $viewsData Views section data + * @param string $modelIdentifier Model identifier + * @param array $propertyDefinitionMap Property definition map + * @param array $elementsLookup Elements lookup for splicing + * @return array Array of processed view objects + */ + private function transformViewsOptimized( + array $viewsData, + string $modelIdentifier, + array $propertyDefinitionMap, + array $elementsLookup + ): array { + $objects = []; + + // Find items in section (optimized version for views) + $items = $this->findItemsSimplified($viewsData, 'view'); + + // OPTIMIZATION: Pre-filter elements to only those actually referenced in views + $referencedElements = $this->extractReferencedElements($items); + $filteredElementsLookup = array_intersect_key($elementsLookup, array_flip($referencedElements)); + + $this->logger->debug('Optimized elements lookup for views', [ + 'total_elements' => count($elementsLookup), + 'referenced_elements' => count($filteredElementsLookup), + 'optimization_ratio' => round((1 - count($filteredElementsLookup) / max(count($elementsLookup), 1)) * 100, 1) . '%' + ]); + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $identifier = $this->extractIdentifier($item, 'view'); + if (!$identifier) { + continue; + } + + // OPTIMIZATION: Use filtered elements lookup for better performance + $essentialXmlData = $this->extractEssentialXmlData($item, $filteredElementsLookup, 'view'); + + $object = [ + '@self' => [ + 'register' => $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'schema' => $this->getSchemaIdForSection('view'), + 'id' => $identifier, + 'owner' => $this->cachedConfig['userId'], + 'organisation' => $this->getCurrentOrganisation(), + + ], + 'identifier' => $identifier, + 'section' => 'view', + 'model_identifier' => $modelIdentifier, + 'xml' => $essentialXmlData + ]; + + // Extract name and summary (same as other sections) + if (isset($item['name'])) { + if (is_array($item['name']) && isset($item['name']['_value'])) { + $object['name'] = $item['name']['_value']; + } elseif (is_string($item['name'])) { + $object['name'] = $item['name']; + } + } + + if (isset($item['documentation'])) { + if (is_array($item['documentation']) && isset($item['documentation']['_value'])) { + $object['summary'] = $item['documentation']['_value']; + } elseif (is_string($item['documentation'])) { + $object['summary'] = $item['documentation']; + } + } + + // Flatten properties efficiently (same as other sections) + if (isset($item['properties']['property']) && !empty($propertyDefinitionMap)) { + $this->flattenPropertiesBatch($object, $item['properties']['property'], $propertyDefinitionMap); + + // Update ID and slug if objectId is available + if (isset($object['objectId'])) { + $object['@self']['id'] = $object['objectId']; + $object['@self']['slug'] = $identifier; + } else { + if ($identifier && str_starts_with($identifier, 'id-')) { + $object['@self']['slug'] = substr($identifier, 3); + } else { + $object['@self']['slug'] = $identifier; + } + } + } else { + if ($identifier && str_starts_with($identifier, 'id-')) { + $object['@self']['slug'] = substr($identifier, 3); + } else { + $object['@self']['slug'] = $identifier; + } + } + + // Copy viewNodes and viewRelationships from XML to root level for easy access + if (isset($object['xml']['viewNodes'])) { + $object['viewNodes'] = $object['xml']['viewNodes']; + } + if (isset($object['xml']['viewRelationships'])) { + $object['viewRelationships'] = $object['xml']['viewRelationships']; + } + + $objects[] = $object; + } + + return $objects; + } + + /** + * Extract all element references from view items for optimization + * + * @param array $viewItems Array of view items + * @return array Array of referenced element identifiers + */ + private function extractReferencedElements(array $viewItems): array + { + $references = []; + + foreach ($viewItems as $item) { + $this->collectElementReferencesRecursively($item, $references); + } + + return array_unique($references); + } + + /** + * Recursively collect element references from view data + * + * @param array $data View data to process + * @param array &$references Array to collect references into (by reference) + * @return void + */ + private function collectElementReferencesRecursively(array $data, array &$references): void + { + // Check for elementRef in current level + if (isset($data['_attributes']['elementRef'])) { + $references[] = $data['_attributes']['elementRef']; + } + + // Recursively check child nodes + if (isset($data['node'])) { + $nodeData = $data['node']; + if (!isset($nodeData[0])) { + $nodeData = [$nodeData]; + } + + foreach ($nodeData as $node) { + $this->collectElementReferencesRecursively($node, $references); + } + } + } + + /** + * Build elements lookup array for view processing with element splicing + * + * This method creates a fast lookup array of elements by their identifier + * to enable efficient element splicing during view node processing. + * + * @param array $elementObjects Array of processed element objects + * @return array Lookup array with element identifier as key and element data as value + */ + private function buildElementsLookup(array $elementObjects): array + { + $lookup = []; + + foreach ($elementObjects as $element) { + $identifier = $element['identifier'] ?? null; + if ($identifier) { + $lookup[$identifier] = $element; + } + } + + $this->logger->debug('Built elements lookup for view processing', [ + 'total_elements' => count($lookup), + 'sample_identifiers' => array_slice(array_keys($lookup), 0, 5) + ]); + + return $lookup; + } + + /** + * SPEED OPTIMIZATION: Build elements lookup directly from raw XML data + * + * This is faster than building from processed objects because we skip intermediate processing + * and build the lookup table directly from the source data with minimal transformations. + * + * @param array $rawElementsData Raw elements data from XML + * @param array $processedObjects Already processed objects (for fallback) + * @param array $propertyDefinitionMap Property definition map + * @return array Elements lookup for view processing + */ + private function buildElementsLookupFromRawData( + array $rawElementsData, + array $processedObjects, + array $propertyDefinitionMap + ): array { + $lookup = []; + + // SPEED: Build directly from raw data with minimal processing + foreach ($rawElementsData as $identifier => $rawItem) { + $element = [ + 'identifier' => $identifier, + 'section' => 'element' + ]; + + // Fast name extraction + if (isset($rawItem['name'])) { + $element['name'] = is_array($rawItem['name']) && isset($rawItem['name']['_value']) + ? $rawItem['name']['_value'] + : (is_string($rawItem['name']) ? $rawItem['name'] : ''); + } + + // Fast summary extraction + if (isset($rawItem['documentation'])) { + $element['summary'] = is_array($rawItem['documentation']) && isset($rawItem['documentation']['_value']) + ? $rawItem['documentation']['_value'] + : (is_string($rawItem['documentation']) ? $rawItem['documentation'] : ''); + } + + // Fast properties flattening (only essential properties for splicing) + if (isset($rawItem['properties']['property']) && !empty($propertyDefinitionMap)) { + $props = isset($rawItem['properties']['property'][0]) + ? $rawItem['properties']['property'] + : [$rawItem['properties']['property']]; + + foreach ($props as $prop) { + if (!isset($prop['_attributes']['propertyDefinitionRef'])) continue; + + $defRef = $prop['_attributes']['propertyDefinitionRef']; + $value = $prop['value']['_value'] ?? $prop['value'] ?? null; + + if ($value !== null && isset($propertyDefinitionMap[$defRef])) { + $propertyName = $propertyDefinitionMap[$defRef]; + $camelCaseName = $this->convertToCamelCase($propertyName); + $element[$camelCaseName] = $value; + } + } + } + + $lookup[$identifier] = $element; + } + + $this->logger->debug('Built SPEED elements lookup from raw data', [ + 'total_elements' => count($lookup), + 'sample_identifiers' => array_slice(array_keys($lookup), 0, 5) + ]); + + return $lookup; + } + + /** + * Create model object directly with cached configuration + * + * @param array $metadata Model metadata + * @param string $modelIdentifier Model identifier + * @return array Model object with @self structure + */ + private function createModelObjectDirect(array $metadata, string $modelIdentifier): array + { + $organisation = $this->getCurrentOrganisation(); + + return [ + '@self' => [ + 'register' => $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'schema' => $this->cachedConfig['schemaIds']['model'] ?? throw new \RuntimeException("Schema ID for 'model' not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'id' => $modelIdentifier, + 'owner' => $this->cachedConfig['userId'], + 'organisation' => $organisation, // Use the method instead of cached value + 'published' => date('Y-m-d\TH:i:s\Z') + ], + 'identifier' => $modelIdentifier, + 'section' => 'model', + 'model_identifier' => $modelIdentifier + ] + $metadata; + } + + /** + * Find section data efficiently without complex nested searches + * + * @param array $xmlData Parsed XML data + * @param string $sectionName Section name to find + * @return array Section data or empty array + */ + private function findSectionData(array $xmlData, string $sectionName): array + { + // Direct lookup first + if (isset($xmlData[$sectionName])) { + return $xmlData[$sectionName]; + } + + // Alternative names lookup + $alternatives = [ + 'views' => ['diagrams'], + 'organizations' => ['organisation'], + 'property_definitions' => ['propertyDefinitions', 'propertydefinitions'] + ]; + + if (isset($alternatives[$sectionName])) { + foreach ($alternatives[$sectionName] as $altName) { + if (isset($xmlData[$altName])) { + return $xmlData[$altName]; + } + } + } + + return []; + } + + /** + * Transform section objects in batch with minimal overhead and element splicing for views + * + * @param array $sectionData Section data from XML + * @param string $schemaType Schema type (singular) + * @param string $modelIdentifier Model identifier + * @param array $propertyDefinitionMap Property definition map + * @param array $elementsLookup Optional elements lookup for view processing + * @return array Array of transformed objects + */ + private function transformSectionObjectsBatch( + array $sectionData, + string $schemaType, + string $modelIdentifier, + array $propertyDefinitionMap, + array $elementsLookup = [] + ): array { + $objects = []; + + // Find items in section (simplified version) + $items = $this->findItemsSimplified($sectionData, $schemaType); + + $skippedNotArray = 0; + $skippedNoIdentifier = 0; + + foreach ($items as $item) { + if (!is_array($item)) { + $skippedNotArray++; + continue; + } + + $identifier = $this->extractIdentifier($item, $schemaType); + if (!$identifier) { + $skippedNoIdentifier++; + continue; + } + + // Create object directly (minimal processing) with element splicing for views + $essentialXmlData = $this->extractEssentialXmlData($item, $elementsLookup, $schemaType); + + $object = [ + '@self' => [ + 'register' => $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'schema' => $this->cachedConfig['schemaIds'][$schemaType] ?? throw new \RuntimeException("Schema ID for '{$schemaType}' not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'id' => $identifier, + 'owner' => $this->cachedConfig['userId'], + 'organisation' => $this->getCurrentOrganisation(), + 'published' => date('Y-m-d\TH:i:s\Z') + ], + 'identifier' => $identifier, + 'section' => $schemaType, + 'model_identifier' => $modelIdentifier, + 'xml' => $essentialXmlData + ]; + + // Debug: Log XML data extraction + $this->logger->debug('XML data extracted for object', [ + 'object_id' => $identifier, + 'section' => $schemaType, + 'original_item_keys' => array_keys($item), + 'essential_xml_keys' => array_keys($essentialXmlData), + 'essential_xml_size' => strlen(json_encode($essentialXmlData)), + 'has_properties' => isset($item['properties']), + 'properties_structure' => isset($item['properties']) ? array_keys($item['properties']) : null + ]); + + // Extract name from XML if it exists + if (isset($item['name'])) { + if (is_array($item['name']) && isset($item['name']['_value'])) { + $object['name'] = $item['name']['_value']; + } elseif (is_string($item['name'])) { + $object['name'] = $item['name']; + } + } + + // Extract documentation from XML if it exists and set to summary + if (isset($item['documentation'])) { + if (is_array($item['documentation']) && isset($item['documentation']['_value'])) { + $object['summary'] = $item['documentation']['_value']; + } elseif (is_string($item['documentation'])) { + $object['summary'] = $item['documentation']; + } + } + + + + // Flatten properties efficiently (if present) + if (isset($item['properties']['property']) && !empty($propertyDefinitionMap)) { + $this->flattenPropertiesBatch($object, $item['properties']['property'], $propertyDefinitionMap); + + // FIXED: After properties are flattened, update ID and slug if objectId is available + if (isset($object['objectId'])) { + // Use objectId as main ID and AMEF identifier as slug + $object['@self']['id'] = $object['objectId']; + $object['@self']['slug'] = $identifier; // AMEF identifier becomes slug + } else { + // Fallback: extract clean UUID from AMEF identifier for slug + if ($identifier && str_starts_with($identifier, 'id-')) { + $object['@self']['slug'] = substr($identifier, 3); // Remove "id-" prefix + } else { + $object['@self']['slug'] = $identifier; + } + } + } else { + // No properties to flatten, use AMEF identifier logic + if ($identifier && str_starts_with($identifier, 'id-')) { + $object['@self']['slug'] = substr($identifier, 3); // Remove "id-" prefix + } else { + $object['@self']['slug'] = $identifier; + } + } + + // NEW: For view objects, copy viewNodes and viewRelationships from XML to root level + if ($schemaType === 'view' && isset($object['xml'])) { + if (isset($object['xml']['viewNodes'])) { + $object['viewNodes'] = $object['xml']['viewNodes']; + } + if (isset($object['xml']['viewRelationships'])) { + $object['viewRelationships'] = $object['xml']['viewRelationships']; + } + } + + // DEBUG: Log final object structure before adding to array + $this->logger->debug('Final object structure before save', [ + 'object_id' => $identifier, + 'section' => $schemaType, + 'object_keys' => array_keys($object), + 'has_xml_property' => isset($object['xml']), + 'xml_keys' => isset($object['xml']) ? array_keys($object['xml']) : null, + 'has_property_mapping' => isset($object['_propertyMapping']), + 'property_mapping_count' => isset($object['_propertyMapping']) ? count($object['_propertyMapping']) : 0, + 'viewNodes_count' => isset($object['viewNodes']) ? count($object['viewNodes']) : 0, + 'viewRelationships_count' => isset($object['viewRelationships']) ? count($object['viewRelationships']) : 0, + 'sample_properties' => array_slice(array_diff(array_keys($object), ['@self', 'identifier', 'section', 'model_identifier', 'xml', '_propertyMapping', 'name', 'summary', 'viewNodes', 'viewRelationships']), 0, 5) + ]); + + $objects[] = $object; + } + + + + return $objects; + } + + /** + * Simplified item finding for better performance + * + * @param array $sectionData Section data + * @param string $sectionType Section type + * @return array Items array + */ + private function findItemsSimplified(array $sectionData, string $sectionType): array + { + // Handle views with diagrams structure + if ($sectionType === 'view' && isset($sectionData['diagrams']['view'])) { + $viewData = $sectionData['diagrams']['view']; + return isset($viewData[0]) ? $viewData : [$viewData]; + } + + // Try common patterns + $patterns = [ + $sectionType, // singular: element, relationship, etc. + $sectionType . 's', // plural: elements, relationships, etc. + 'item', // organizations use 'item' + 'propertyDefinition' // property definitions + ]; + + foreach ($patterns as $pattern) { + if (isset($sectionData[$pattern])) { + $data = $sectionData[$pattern]; + return is_array($data) && isset($data[0]) ? $data : [$data]; + } + } + + // Fallback: treat section data as single item + return [$sectionData]; + } + + /** + * Flatten properties in batch for better performance + * + * @param array &$object Object to add properties to (by reference) + * @param array $properties Properties array from XML + * @param array $propertyDefinitionMap Property definition map + * @return void + */ + private function flattenPropertiesBatch(array &$object, array $properties, array $propertyDefinitionMap): void + { + $props = isset($properties[0]) ? $properties : [$properties]; + $processedProperties = []; + + // Debug: Log property flattening process + $this->logger->debug('Flattening properties for object', [ + 'object_id' => $object['identifier'] ?? 'unknown', + 'properties_count' => count($props), + 'property_definition_map_size' => count($propertyDefinitionMap), + 'sample_property_definitions' => array_slice($propertyDefinitionMap, 0, 5, true) + ]); + + foreach ($props as $propIndex => $prop) { + if (!isset($prop['_attributes']['propertyDefinitionRef'])) { + $this->logger->warning('Property missing propertyDefinitionRef', [ + 'object_id' => $object['identifier'] ?? 'unknown', + 'property_index' => $propIndex, + 'property_structure' => array_keys($prop ?? []) + ]); + continue; + } + + $defRef = $prop['_attributes']['propertyDefinitionRef']; + $value = $prop['value']['_value'] ?? $prop['value'] ?? null; + + // Debug: Log property reference lookup + if (!isset($propertyDefinitionMap[$defRef])) { + $this->logger->warning('Property definition not found in map', [ + 'object_id' => $object['identifier'] ?? 'unknown', + 'property_def_ref' => $defRef, + 'available_refs' => array_keys($propertyDefinitionMap) + ]); + continue; + } + + if ($value !== null && isset($propertyDefinitionMap[$defRef])) { + $propertyName = $propertyDefinitionMap[$defRef]; + $camelCaseName = $this->convertToCamelCase($propertyName); + $object[$camelCaseName] = $value; + + // Store property mapping for reference + if (!isset($object['_propertyMapping'])) { + $object['_propertyMapping'] = []; + } + $object['_propertyMapping'][$camelCaseName] = $propertyName; + + $processedProperties[] = [ + 'original' => $propertyName, + 'camelCase' => $camelCaseName, + 'value' => $value, + 'def_ref' => $defRef + ]; + + // Object ID property is now handled after property flattening is complete + + // Debug: Log GEMMA type properties specifically + if (stripos($propertyName, 'gemma') !== false || $defRef === 'propid-3') { + $this->logger->info('GEMMA type property processed', [ + 'object_id' => $object['identifier'] ?? 'unknown', + 'property_name' => $propertyName, + 'camel_case_name' => $camelCaseName, + 'value' => $value, + 'def_ref' => $defRef + ]); + } + } else { + $this->logger->warning('Property value is null or mapping missing', [ + 'object_id' => $object['identifier'] ?? 'unknown', + 'property_def_ref' => $defRef, + 'value' => $value, + 'mapping_exists' => isset($propertyDefinitionMap[$defRef]) + ]); + } + } + + // Debug: Log final property flattening results + $this->logger->debug('Property flattening completed', [ + 'object_id' => $object['identifier'] ?? 'unknown', + 'processed_count' => count($processedProperties), + 'processed_properties' => $processedProperties, + 'object_keys_after_flattening' => array_keys($object) + ]); + } + + /** + * SPEED OPTIMIZATION: Build all lookups simultaneously for maximum performance + * + * Pre-builds all possible lookups in parallel to eliminate lookup building overhead + * during processing. Uses more memory but significantly faster processing. + * + * @param array $xmlData Complete XML data + * @return array Array with all lookups: ['elements' => [...], 'relationships' => [...], etc.] + */ + private function buildAllLookupsSimultaneously(array $xmlData): array + { + $lookups = [ + 'elements' => [], + 'relationships' => [], + 'organizations' => [], + 'views' => [], + 'property_definitions' => [] + ]; + + // Pre-extract all section data simultaneously + $sections = [ + 'elements' => 'element', + 'relationships' => 'relationship', + 'organizations' => 'organization', + 'views' => 'view', + 'property_definitions' => 'property_definition' + ]; + + foreach ($sections as $sectionName => $schemaType) { + $sectionData = $this->findSectionData($xmlData, $sectionName); + if (!empty($sectionData)) { + $items = $this->findItemsSimplified($sectionData, $schemaType); + + foreach ($items as $item) { + if (!is_array($item)) continue; + + $identifier = $this->extractIdentifier($item, $schemaType); + if ($identifier) { + // Store raw item data for fast processing later + $lookups[$sectionName][$identifier] = $item; + } + } + } + } + + return $lookups; + } + + /** + * SPEED OPTIMIZATION: Bulk process all non-view sections with vectorized operations + * + * @param array $xmlData XML data + * @param string $modelIdentifier Model identifier + * @param array $propertyDefinitionMap Property definition map + * @param array $allLookups All pre-built lookups + * @return array Processed objects + */ + private function bulkProcessNonViewSections( + array $xmlData, + string $modelIdentifier, + array $propertyDefinitionMap, + array $allLookups + ): array { + $objects = []; + + $sections = [ + 'elements' => 'element', + 'relationships' => 'relationship', + 'organizations' => 'organization', + 'property_definitions' => 'property_definition' + ]; + + foreach ($sections as $sectionName => $schemaType) { + if (empty($allLookups[$sectionName])) continue; + + $this->logger->debug("SPEED: Bulk processing {$sectionName}", [ + 'item_count' => count($allLookups[$sectionName]) + ]); + + // SPEED OPTIMIZATION: Process all items in this section as a batch + $sectionObjects = $this->bulkTransformSection( + $allLookups[$sectionName], + $schemaType, + $modelIdentifier, + $propertyDefinitionMap + ); + + $objects = array_merge($objects, $sectionObjects); + } + + return $objects; + } + + /** + * SPEED OPTIMIZATION: Bulk transform a section with vectorized operations + * + * @param array $sectionItems Pre-loaded section items by identifier + * @param string $schemaType Schema type + * @param string $modelIdentifier Model identifier + * @param array $propertyDefinitionMap Property definition map + * @return array Transformed objects + */ + private function bulkTransformSection( + array $sectionItems, + string $schemaType, + string $modelIdentifier, + array $propertyDefinitionMap + ): array { + $objects = []; + + foreach ($sectionItems as $identifier => $item) { + // SPEED OPTIMIZATION: Direct object creation without intermediate steps + $essentialXmlData = $this->extractEssentialXmlData($item, [], $schemaType); + + $object = [ + '@self' => [ + 'register' => $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'schema' => $this->cachedConfig['schemaIds'][$schemaType] ?? throw new \RuntimeException("Schema ID for '{$schemaType}' not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'id' => $identifier, + 'owner' => $this->cachedConfig['userId'], + 'organisation' => $this->getCurrentOrganisation(), + 'published' => date('Y-m-d\TH:i:s\Z') + ], + 'identifier' => $identifier, + 'section' => $schemaType, + 'model_identifier' => $modelIdentifier, + 'xml' => $essentialXmlData + ]; + + // Fast extract name and summary + if (isset($item['name'])) { + $object['name'] = is_array($item['name']) && isset($item['name']['_value']) + ? $item['name']['_value'] + : (is_string($item['name']) ? $item['name'] : ''); + } + + if (isset($item['documentation'])) { + $object['summary'] = is_array($item['documentation']) && isset($item['documentation']['_value']) + ? $item['documentation']['_value'] + : (is_string($item['documentation']) ? $item['documentation'] : ''); + } + + // Fast flatten properties + if (isset($item['properties']['property']) && !empty($propertyDefinitionMap)) { + $this->flattenPropertiesBatch($object, $item['properties']['property'], $propertyDefinitionMap); + + // Fast ID/slug update + if (isset($object['objectId'])) { + $object['@self']['id'] = $object['objectId']; + $object['@self']['slug'] = $identifier; + } else { + $object['@self']['slug'] = str_starts_with($identifier, 'id-') + ? substr($identifier, 3) + : $identifier; + } + } else { + $object['@self']['slug'] = str_starts_with($identifier, 'id-') + ? substr($identifier, 3) + : $identifier; + } + + $objects[] = $object; + } + + return $objects; + } + + /** + * SPEED OPTIMIZATION: Process views with maximum speed optimizations + * + * @param array $xmlData XML data + * @param string $modelIdentifier Model identifier + * @param array $propertyDefinitionMap Property definition map + * @param array $elementsLookup Elements lookup for splicing + * @return array Processed view objects + */ + private function processViewsMaximumSpeed( + array $xmlData, + string $modelIdentifier, + array $propertyDefinitionMap, + array $elementsLookup + ): array { + $viewsData = $this->findSectionData($xmlData, 'views'); + if (empty($viewsData)) { + return []; + } + + $this->logger->info('SPEED MODE: Processing views with maximum optimizations', [ + 'elements_available' => count($elementsLookup) + ]); + + // SPEED OPTIMIZATION: Pre-extract all referenced elements + $items = $this->findItemsSimplified($viewsData, 'view'); + $referencedElements = $this->extractReferencedElements($items); + + // SPEED OPTIMIZATION: Build super-fast lookup with array_intersect_key + $filteredElementsLookup = array_intersect_key($elementsLookup, array_flip($referencedElements)); + + $this->logger->debug('SPEED: Optimized element references', [ + 'total_elements' => count($elementsLookup), + 'referenced_elements' => count($filteredElementsLookup), + 'memory_savings_percent' => round((1 - count($filteredElementsLookup) / max(count($elementsLookup), 1)) * 100, 1) + ]); + + // SPEED OPTIMIZATION: Process with bulk operations + return $this->bulkTransformViews($items, $modelIdentifier, $propertyDefinitionMap, $filteredElementsLookup); + } + + /** + * SPEED OPTIMIZATION: Bulk transform views with vectorized element splicing + * + * @param array $viewItems View items to process + * @param string $modelIdentifier Model identifier + * @param array $propertyDefinitionMap Property definition map + * @param array $elementsLookup Filtered elements lookup + * @return array Processed view objects + */ + private function bulkTransformViews( + array $viewItems, + string $modelIdentifier, + array $propertyDefinitionMap, + array $elementsLookup + ): array { + $objects = []; + + foreach ($viewItems as $item) { + if (!is_array($item)) continue; + + $identifier = $this->extractIdentifier($item, 'view'); + if (!$identifier) continue; + + // SPEED OPTIMIZATION: Direct processing with minimal overhead + $essentialXmlData = $this->extractEssentialXmlData($item, $elementsLookup, 'view'); + + $object = [ + '@self' => [ + 'register' => $this->cachedConfig['registerId'] ?? throw new \RuntimeException("Register ID not found in cached configuration. Please ensure AMEF configuration is properly initialized."), + 'schema' => $this->getSchemaIdForSection('view'), + 'id' => $identifier, + 'owner' => $this->cachedConfig['userId'], + 'organisation' => $this->getCurrentOrganisation(), + 'published' => date('Y-m-d\TH:i:s\Z') + ], + 'identifier' => $identifier, + 'section' => 'view', + 'model_identifier' => $modelIdentifier, + 'xml' => $essentialXmlData + ]; + + // Fast name/summary extraction + if (isset($item['name'])) { + $object['name'] = is_array($item['name']) && isset($item['name']['_value']) + ? $item['name']['_value'] + : (is_string($item['name']) ? $item['name'] : ''); + } + + if (isset($item['documentation'])) { + $object['summary'] = is_array($item['documentation']) && isset($item['documentation']['_value']) + ? $item['documentation']['_value'] + : (is_string($item['documentation']) ? $item['documentation'] : ''); + } + + // Fast properties flattening + if (isset($item['properties']['property']) && !empty($propertyDefinitionMap)) { + $this->flattenPropertiesBatch($object, $item['properties']['property'], $propertyDefinitionMap); + + if (isset($object['objectId'])) { + $object['@self']['id'] = $object['objectId']; + $object['@self']['slug'] = $identifier; + } else { + $object['@self']['slug'] = str_starts_with($identifier, 'id-') + ? substr($identifier, 3) + : $identifier; + } + } else { + $object['@self']['slug'] = str_starts_with($identifier, 'id-') + ? substr($identifier, 3) + : $identifier; + } + + // SPEED OPTIMIZATION: Direct copy without checks (we know it exists) + if (isset($object['xml']['viewNodes'])) { + $object['viewNodes'] = $object['xml']['viewNodes']; + } + if (isset($object['xml']['viewRelationships'])) { + $object['viewRelationships'] = $object['xml']['viewRelationships']; + } + + $objects[] = $object; + } + + return $objects; + } + + /** + * Create intelligent batches based on object size to prevent MySQL packet size issues + * + * This method analyzes object sizes and creates batches that stay under the MySQL + * max_allowed_packet limit while maintaining reasonable performance. + * + * TODO: Move this intelligent batch sizing to OpenRegister core as a native feature + * This functionality should be available for all bulk operations, not just ArchiMate imports. + * OpenRegister's saveObjects() method should handle this automatically based on object sizes. + * + * @param array $objects Array of objects to batch + * @return array Array of batches, each containing objects that fit within size limits + */ + private function createIntelligentBatches(array $objects): array + { + $maxBatchSizeBytes = self::PERFORMANCE_OPTIMIZATIONS['max_batch_size_bytes']; + $minBatchSize = self::PERFORMANCE_OPTIMIZATIONS['min_batch_size']; + $sampleSize = self::PERFORMANCE_OPTIMIZATIONS['size_estimation_sample']; + + if (empty($objects)) { + return []; + } + + // Estimate average object size by sampling + $avgObjectSize = $this->estimateAverageObjectSize($objects, $sampleSize); + + // Calculate optimal batch size based on object size + $optimalBatchSize = max($minBatchSize, intval($maxBatchSizeBytes / $avgObjectSize)); + + $this->logger->info('Intelligent batch sizing analysis', [ + 'total_objects' => count($objects), + 'estimated_avg_object_size_bytes' => $avgObjectSize, + 'max_batch_size_bytes' => $maxBatchSizeBytes, + 'calculated_optimal_batch_size' => $optimalBatchSize, + 'min_batch_size_enforced' => $minBatchSize + ]); + + // Create batches with size awareness + $batches = []; + $currentBatch = []; + $currentBatchSize = 0; + + foreach ($objects as $object) { + $objectSize = $this->estimateObjectSize($object); + + // Check if adding this object would exceed the batch size limit + if (!empty($currentBatch) && ($currentBatchSize + $objectSize) > $maxBatchSizeBytes) { + // Current batch is full, save it and start a new one + $batches[] = $currentBatch; + $currentBatch = [$object]; + $currentBatchSize = $objectSize; + } else { + // Add object to current batch + $currentBatch[] = $object; + $currentBatchSize += $objectSize; + } + + // Safety check: if a single object is larger than max batch size, + // create a batch with just that object + if (count($currentBatch) === 1 && $objectSize > $maxBatchSizeBytes) { + $this->logger->warning('Very large object detected, creating single-object batch', [ + 'object_id' => $object['@self']['id'] ?? 'unknown', + 'object_size_bytes' => $objectSize, + 'max_batch_size_bytes' => $maxBatchSizeBytes + ]); + $batches[] = $currentBatch; + $currentBatch = []; + $currentBatchSize = 0; + } + } + + // Add the last batch if it has objects + if (!empty($currentBatch)) { + $batches[] = $currentBatch; + } + + $this->logger->info('Intelligent batching completed', [ + 'total_objects' => count($objects), + 'total_batches_created' => count($batches), + 'batch_sizes' => array_map('count', $batches), + 'estimated_batch_sizes_bytes' => array_map(fn($batch) => array_sum(array_map([$this, 'estimateObjectSize'], $batch)), $batches) + ]); + + return $batches; + } + + /** + * Estimate the average size of objects by sampling + * + * @param array $objects Array of objects to sample + * @param int $sampleSize Number of objects to sample for size estimation + * @return int Estimated average object size in bytes + */ + private function estimateAverageObjectSize(array $objects, int $sampleSize): int + { + $totalObjects = count($objects); + if ($totalObjects === 0) { + return 1000; // Default fallback size + } + + // Sample evenly distributed objects + $sampleIndices = []; + if ($totalObjects <= $sampleSize) { + // Use all objects if we have fewer than sample size + $sampleIndices = range(0, $totalObjects - 1); + } else { + // Sample evenly across the array + $step = max(1, intval($totalObjects / $sampleSize)); + for ($i = 0; $i < $totalObjects; $i += $step) { + $sampleIndices[] = $i; + if (count($sampleIndices) >= $sampleSize) { + break; + } + } + } + + // Calculate sizes of sampled objects + $totalSampleSize = 0; + foreach ($sampleIndices as $index) { + $totalSampleSize += $this->estimateObjectSize($objects[$index]); + } + + $averageSize = intval($totalSampleSize / count($sampleIndices)); + + $this->logger->debug('Object size estimation completed', [ + 'total_objects' => $totalObjects, + 'sampled_objects' => count($sampleIndices), + 'total_sample_size_bytes' => $totalSampleSize, + 'estimated_average_size_bytes' => $averageSize + ]); + + return max(1000, $averageSize); // Minimum 1KB per object + } + + /** + * Estimate the serialized size of an object for batching purposes + * + * @param array $object The object to estimate size for + * @return int Estimated size in bytes + */ + private function estimateObjectSize(array $object): int + { + // Quick estimation based on JSON serialization + // This includes overhead for SQL parameters and structure + $jsonSize = strlen(json_encode($object)); + + // Add overhead for SQL INSERT statement structure + // Each object becomes multiple parameters in a bulk INSERT + $sqlOverhead = 500; // Estimated overhead per object in SQL + + return $jsonSize + $sqlOverhead; + } + + /** + * Calculate detailed object statistics for import operations + * + * @param array $normalizedData Normalized ArchiMate data + * @param array $savedObjects Objects that were saved to database + * @return array Comprehensive statistics + */ + private function calculateObjectStatistics(array $normalizedData, array $savedObjects): array + { + // Initialize statistics structure + $statistics = [ + 'elements' => ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'errors' => []], + 'organizations' => ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'errors' => []], + 'relationships' => ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'errors' => []], + 'views' => ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'errors' => []], + 'property_definitions' => ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'errors' => []] + ]; + + // If we have access to the actual save results from ObjectService, use those + if ($this->lastSaveResult !== null) { + $saveResult = $this->lastSaveResult; + + // Count objects by section type from the actual processed objects + $allProcessedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [], + $saveResult['unchanged'] ?? $saveResult['skipped'] ?? [], + // For invalid objects, extract the original object from the error structure + array_map(fn($item) => $item['object'] ?? [], $saveResult['invalid'] ?? []) + ); + + foreach ($allProcessedObjects as $object) { + // Convert ObjectEntity to array if needed + if (is_object($object) && method_exists($object, 'jsonSerialize')) { + $object = $object->jsonSerialize(); + } + + $sectionType = $object['section'] ?? 'elements'; // Default to elements if section not found + + // Map section types to statistics keys + $sectionKey = match($sectionType) { + 'elements' => 'elements', + 'relationships' => 'relationships', + 'organizations' => 'organizations', + 'views' => 'views', + 'property_definitions' => 'property_definitions', + default => 'elements' // Default fallback + }; + + if (!isset($statistics[$sectionKey])) { + continue; // Skip unknown section types + } + + // Determine if this object was created, updated, or had errors + $objectId = $object['@self']['id'] ?? $object['identifier'] ?? null; + + // Check if this object is in the saved (created) list + $wasCreated = !empty(array_filter($saveResult['saved'] ?? [], + fn($saved) => ($saved->getUuid() === $objectId))); + + // Check if this object is in the updated list + $wasUpdated = !empty(array_filter($saveResult['updated'] ?? [], + fn($updated) => ($updated->getUuid() === $objectId))); + + // Check if this object was unchanged (no changes) + $unchangedObjects = $saveResult['unchanged'] ?? $saveResult['skipped'] ?? []; + $wasSkipped = !empty(array_filter($unchangedObjects, + fn($unchanged) => ($unchanged->getUuid() === $objectId))); + + // Check if this object had validation errors + $hasErrors = !empty(array_filter($saveResult['invalid'] ?? [], + fn($invalid) => (($invalid['object']['@self']['id'] ?? null) === $objectId))); + + if ($wasCreated) { + $statistics[$sectionKey]['created']++; + } elseif ($wasUpdated) { + $statistics[$sectionKey]['updated']++; + } elseif ($wasSkipped) { + $statistics[$sectionKey]['skipped']++; + } elseif ($hasErrors) { + // Add to errors array for this section + $errorInfo = array_filter($saveResult['invalid'] ?? [], + fn($invalid) => (($invalid['object']['@self']['id'] ?? null) === $objectId)); + + if (!empty($errorInfo)) { + $statistics[$sectionKey]['errors'][] = array_values($errorInfo)[0]['error'] ?? 'Unknown validation error'; + } + } else { + // This shouldn't happen, but leave as fallback + $statistics[$sectionKey]['skipped']++; + } + } + } else { + // Fallback to old method if no save result is available + $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; + foreach ($sections as $section) { + if (isset($normalizedData[$section])) { + $count = count($normalizedData[$section]); + // Assume all objects were created (legacy behavior) + $statistics[$section]['created'] = $count; + } + } + } + + // Calculate summary totals from actual statistics + $summary = [ + 'total_objects_created' => 0, + 'total_objects_updated' => 0, + 'total_objects_deleted' => 0, + 'total_objects_skipped' => 0, + 'total_errors' => 0 + ]; + + foreach ($statistics as $section => $sectionStats) { + if ($section !== 'summary') { // Skip summary section itself + $summary['total_objects_created'] += $sectionStats['created']; + $summary['total_objects_updated'] += $sectionStats['updated']; + $summary['total_objects_skipped'] += $sectionStats['skipped']; + $summary['total_errors'] += count($sectionStats['errors']); + } + } + + $statistics['summary'] = $summary; + + return $statistics; + } + + /** + * Extract detailed error information from import statistics for frontend display + * + * @param array $statistics Import statistics containing section-wise error data + * @return array Formatted error information for frontend consumption + */ + private function extractDetailedErrors(array $statistics): array + { + $detailedErrors = [ + 'total_count' => 0, + 'by_section' => [], + 'summary' => [] + ]; + + $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; + + foreach ($sections as $section) { + if (isset($statistics[$section]['errors']) && !empty($statistics[$section]['errors'])) { + $sectionErrors = $statistics[$section]['errors']; + $sectionErrorCount = count($sectionErrors); + + $detailedErrors['total_count'] += $sectionErrorCount; + + // Group errors by type/message for better presentation + $errorGroups = []; + foreach ($sectionErrors as $error) { + $errorMessage = is_string($error) ? $error : ($error['message'] ?? 'Unknown error'); + $errorType = $this->categorizeError($errorMessage); + + if (!isset($errorGroups[$errorType])) { + $errorGroups[$errorType] = [ + 'type' => $errorType, + 'message' => $errorMessage, + 'count' => 0, + 'examples' => [] + ]; + } + + $errorGroups[$errorType]['count']++; + + // Add example object ID if available (limit to 5 examples) + if (count($errorGroups[$errorType]['examples']) < 5) { + if (is_array($error) && isset($error['object_id'])) { + $errorGroups[$errorType]['examples'][] = $error['object_id']; + } + } + } + + $detailedErrors['by_section'][$section] = [ + 'section_name' => ucfirst(str_replace('_', ' ', $section)), + 'total_errors' => $sectionErrorCount, + 'error_groups' => array_values($errorGroups) + ]; + } + } + + // Create summary of most common errors across all sections + $allErrors = []; + foreach ($detailedErrors['by_section'] as $sectionData) { + foreach ($sectionData['error_groups'] as $errorGroup) { + $errorType = $errorGroup['type']; + if (!isset($allErrors[$errorType])) { + $allErrors[$errorType] = [ + 'type' => $errorType, + 'message' => $errorGroup['message'], + 'total_count' => 0, + 'affected_sections' => [] + ]; + } + $allErrors[$errorType]['total_count'] += $errorGroup['count']; + $allErrors[$errorType]['affected_sections'][] = $sectionData['section_name']; + } + } + + // Sort by frequency and take top 10 + uasort($allErrors, fn($a, $b) => $b['total_count'] - $a['total_count']); + $detailedErrors['summary'] = array_slice(array_values($allErrors), 0, 10); + + return $detailedErrors; + } + + /** + * Categorize error types for better grouping and presentation + * + * @param string $errorMessage The error message to categorize + * @return string Error category/type + */ + private function categorizeError(string $errorMessage): string + { + $errorMessage = strtolower($errorMessage); + + // Define error patterns and their categories + $errorPatterns = [ + 'validation' => ['validation', 'invalid', 'required', 'missing', 'empty'], + 'schema' => ['schema', 'structure', 'format', 'type'], + 'reference' => ['reference', 'identifier', 'not found', 'missing reference'], + 'property' => ['property', 'attribute', 'field'], + 'constraint' => ['constraint', 'unique', 'duplicate', 'already exists'], + 'relationship' => ['relationship', 'source', 'target', 'connection'], + 'data_type' => ['string', 'integer', 'boolean', 'array', 'object'], + 'encoding' => ['encoding', 'character', 'utf', 'ascii'], + ]; + + foreach ($errorPatterns as $category => $patterns) { + foreach ($patterns as $pattern) { + if (str_contains($errorMessage, $pattern)) { + return $category; + } + } + } + + return 'general'; + } } diff --git a/lib/Service/ArchiMateService.php b/lib/Service/ArchiMateService.php index 871b79bd..5578330f 100644 --- a/lib/Service/ArchiMateService.php +++ b/lib/Service/ArchiMateService.php @@ -20,6 +20,7 @@ namespace OCA\SoftwareCatalog\Service; use OCA\OpenRegister\Service\ObjectService; +use OCA\SoftwareCatalog\Service\SettingsService; use OCP\App\IAppManager; use OCP\IAppConfig; use OCP\IUserSession; @@ -48,6 +49,41 @@ class ArchiMateService /** * Configuration keys for ArchiMate processing */ + + /** + * Store last save operation timing breakdown for performance metrics + * + * @var array + */ + private array $lastSaveTimingBreakdown = []; + + /** + * Cache for camelCase property name conversions to avoid redundant processing + * + * @var array + */ + private array $camelCaseCache = []; + + /** + * Cache for identifier extraction patterns by section type + * + * @var array + */ + private array $identifierPatternCache = []; + + /** + * Cache for property definition maps to avoid rebuilding during import + * + * @var array|null + */ + private ?array $propertyDefinitionMapCache = null; + + /** + * Flag to track if we've already logged finding a GEMMA type property + * + * @var bool + */ + private bool $gemmaTypePropertyFound = false; private const CONFIG_KEYS = [ 'archimate_register_id' => 'archimate_register_id', 'archimate_schema_id' => 'archimate_schema_id', @@ -55,17 +91,39 @@ class ArchiMateService ]; /** - * Default schema IDs for ArchiMate objects + * Performance optimization settings */ - private const DEFAULT_SCHEMA_IDS = [ - 'model' => 100, - 'element' => 101, - 'relationship' => 102, - 'view' => 103, - 'organization' => 104, - 'property_definition' => 105 + private const PERFORMANCE_OPTIMIZATIONS = [ + 'disable_validation' => true, + 'disable_events' => true, + 'disable_rbac' => false, // Keep RBAC for security + 'use_multi' => true, + 'xml_parse_flags' => LIBXML_NOCDATA | LIBXML_NONET, + 'memory_cleanup' => true, + 'parallel_processing' => true, + 'batch_size' => 1000, // Default batch size (will be adjusted intelligently) + 'parallel_batches' => 8, // Process 8 batches concurrently + 'max_batch_size_bytes' => 8388608, // 8 MB - safe under MySQL's 16 MB limit + 'min_batch_size' => 50, // Minimum batch size for very large objects + 'size_estimation_sample' => 10 // Sample size for estimating object sizes ]; + /** + * NOTE: Default schema IDs removed - all schema IDs must be configured via AMEF settings. + * The system will fail gracefully with clear error messages if configuration is missing. + */ + + /** + * Storage for the last save operation results + * Contains the structured return from ObjectService::saveObjects + */ + private ?array $lastSaveResult = null; + + /** + * Cached configuration values for performance optimization + */ + private ?array $cachedConfig = null; + /** * Constructor for ArchiMateService * @@ -75,6 +133,7 @@ class ArchiMateService * @param IAppManager $appManager App manager service * @param ContainerInterface $container PSR-11 container interface * @param LoggerInterface $logger Logger service + * @param SettingsService $settingsService Settings service for schema and organization configuration * @param ArchiMateImportService $importService Import service for XML parsing * @param ArchiMateExportService $exportService Export service for XML generation */ @@ -85,17 +144,31 @@ public function __construct( private readonly IAppManager $appManager, private readonly ContainerInterface $container, private readonly LoggerInterface $logger, + private readonly SettingsService $settingsService, private readonly ArchiMateImportService $importService, private readonly ArchiMateExportService $exportService ) { } /** - * Import ArchiMate XML file from path + * OPTIMIZED: Import ArchiMate XML file using OpenRegister-style performance optimization * - * @param array $options Import options - * @return array Import results + * This method follows the same pattern as OpenRegister ImportService: + * 1. Parse ALL XML data first (single pass) + * 2. Transform to objects array (batch processing) + * 3. Single saveObjects() call with all objects + * + * Expected performance: <1 minute for 8000 objects (vs current 13 minutes) + * + * @param array $options Import options including file_path, fileName, etc. + * @return array Import results with detailed status */ + public function importArchiMateFileFromPathOptimized(array $options = []): array + { + // Delegate to the import service + return $this->importService->importArchiMateFileFromPathOptimized($options); + } + /** * Import ArchiMate XML file from path with model detection and round-trip fidelity * @@ -111,134 +184,8 @@ public function __construct( */ public function importArchiMateFileFromPath(array $options = []): array { - // Track start time and memory for performance metrics - $startTime = microtime(true); - $startMemory = memory_get_usage(true); - - $this->logger->info('Starting ArchiMate XML import with model detection', [ - 'options' => $options, - 'file_path' => $options['file_path'] ?? 'unknown' - ]); - - try { - // STEP 1: Parse XML to array using the specialized import service - // This captures ALL possible XML values including attributes, text content, and nested elements - $filePath = $options['filePath'] ?? $options['file_path'] ?? ''; - - if (empty($filePath)) { - throw new \InvalidArgumentException('File path is required for import'); - } - - if (!file_exists($filePath)) { - throw new \InvalidArgumentException("File not found: {$filePath}"); - } - - $this->logger->info('Step 1: Parsing XML to array for complete data capture', ['filePath' => $filePath]); - $parseStartTime = microtime(true); - $xmlData = $this->parseArchiMateXml($filePath); - $parseTime = microtime(true) - $parseStartTime; - - // STEP 2: Extract model identifier and detect if model already exists - // This is critical for determining whether to create new or update existing model - $this->logger->info('Step 2: Extracting model identifier and checking for existing model'); - $validationStartTime = microtime(true); - $modelIdentifier = $this->extractModelIdentifier($xmlData); - $modelExists = $this->checkIfModelExists($modelIdentifier); - $validationTime = microtime(true) - $validationStartTime; - - // STEP 3: Normalize data structure for storage as JSON blob - // Store complete raw XML data for exact round-trip fidelity during export - $this->logger->info('Step 3: Normalizing data structure for JSON blob storage'); - $normalizedData = $this->normalizeArchiMateData($xmlData, $modelIdentifier); - - // STEP 4: Convert to OpenRegister objects with proper @self structure - // Each object must have @self with register, schema, and id for ObjectService::saveObjects - $this->logger->info('Step 4: Converting to OpenRegister objects with @self structure'); - $convertStartTime = microtime(true); - $objects = $this->convertToOpenRegisterObjects($normalizedData, $modelIdentifier); - $convertTime = microtime(true) - $convertStartTime; - - // STEP 5: Save objects using ObjectService::saveObjects - // This handles the actual database persistence with proper validation - $this->logger->info('Step 5: Saving objects to database using ObjectService::saveObjects'); - $savedObjects = $this->saveObjectsToDatabase($objects); - - // Calculate total time and memory usage - $totalTime = microtime(true) - $startTime; - $endMemory = memory_get_usage(true); - $peakMemory = memory_get_peak_usage(true); - - // Count objects by type for detailed statistics - $statistics = $this->calculateObjectStatistics($normalizedData, $savedObjects); - - // Calculate performance metrics - $totalObjects = $statistics['summary']['total_objects_created'] + $statistics['summary']['total_objects_updated']; - $itemsPerSecond = $totalObjects > 0 ? $totalObjects / $totalTime : 0; - - // Prepare comprehensive result with detailed information - $result = [ - 'success' => true, - 'file_info' => [ - 'name' => $options['fileName'] ?? basename($filePath), - 'size' => filesize($filePath), - 'mime_type' => $options['mimeType'] ?? 'text/xml' - ], - 'processing_times' => [ - 'total_time_seconds' => round($totalTime, 3), - 'validation_time_seconds' => round($validationTime, 3), - 'parse_time_seconds' => round($parseTime, 3), - 'convert_time_seconds' => round($convertTime, 3), - 'performance_breakdown' => [ - 'validation_percent' => round(($validationTime / $totalTime) * 100, 1), - 'parse_percent' => round(($parseTime / $totalTime) * 100, 1), - 'convert_percent' => round(($convertTime / $totalTime) * 100, 1) - ] - ], - 'memory_usage' => [ - 'start_mb' => round($startMemory / 1024 / 1024, 1), - 'end_mb' => round($endMemory / 1024 / 1024, 1), - 'peak_mb' => round($peakMemory / 1024 / 1024, 2), - 'total_used_mb' => round(($endMemory - $startMemory) / 1024 / 1024, 1) - ], - 'statistics' => $statistics, - 'summary' => [ - 'total_objects_created' => $statistics['summary']['total_objects_created'], - 'total_objects_updated' => $statistics['summary']['total_objects_updated'], - 'total_objects_deleted' => $statistics['summary']['total_objects_deleted'], - 'total_objects_skipped' => $statistics['summary']['total_objects_skipped'], - 'total_errors' => $statistics['summary']['total_errors'] - ], - 'performance_metrics' => [ - 'items_per_second' => round($itemsPerSecond, 2), - 'processing_method' => 'synchronous_batch_processing', - 'batch_size_used' => 100, - 'dataset_size' => $totalObjects - ] - ]; - - $this->logger->info('ArchiMate XML import completed successfully', [ - 'model_identifier' => $modelIdentifier, - 'model_exists' => $modelExists, - 'imported_objects' => $totalObjects, - 'round_trip_fidelity' => 'enabled' - ]); - - return $result; - - } catch (\Exception $e) { - $this->logger->error('ArchiMate XML import failed', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - 'file_path' => $options['filePath'] ?? $options['file_path'] ?? 'unknown' - ]); - - return [ - 'success' => false, - 'message' => 'Import failed: ' . $e->getMessage(), - 'error' => $e->getMessage(), - 'step_failed' => 'unknown' // Will be refined with better error tracking - ]; - } + // Delegate to the import service + return $this->importService->importArchiMateFileFromPath($options); } /** @@ -262,7 +209,7 @@ public function exportToArchiMate(?string $organization = null): array $registerId = $this->getAmefRegisterId(); if (!$registerId) { - $registerId = 15; // Fallback + throw new \RuntimeException('AMEF register ID is not configured. Please configure the AMEF register via the admin interface.'); } // Create schema ID mapping for the export service @@ -306,7 +253,7 @@ private function createSchemaIdMap(): array $schemaIdMap = []; foreach ($schemaTypes as $schemaType) { - $schemaId = $this->getAmefSchemaIdForType($schemaType); + $schemaId = $this->settingsService->getSchemaIdForObjectType($schemaType); if ($schemaId) { $schemaIdMap[$schemaId] = $schemaType; } @@ -315,459 +262,17 @@ private function createSchemaIdMap(): array return $schemaIdMap; } - /** - * Parse ArchiMate XML file to array using the import service - * - * @param string $filePath Path to XML file - * @return array Parsed XML data - */ - private function parseArchiMateXml(string $filePath): array - { - if (!file_exists($filePath)) { - throw new \InvalidArgumentException("File not found: {$filePath}"); - } - - $xmlContent = file_get_contents($filePath); - if ($xmlContent === false) { - throw new \RuntimeException("Failed to read file: {$filePath}"); - } - - $xml = new SimpleXMLElement($xmlContent); - return $this->importService->xmlToArray($xml); - } - - /** - * Extract model identifier from parsed XML data - * - * This method looks for the model identifier in various locations within the XML: - * 1. Root level _attributes.identifier (most common) - * 2. Model element attributes - * 3. Fallback to generated identifier if none found - * - * @param array $xmlData Parsed XML data array - * @return string Model identifier for tracking and storage - */ - private function extractModelIdentifier(array $xmlData): string - { - $this->logger->debug('Extracting model identifier from XML data', [ - 'xml_keys' => array_keys($xmlData) - ]); - - // STEP 1: Try to find identifier in root attributes (most common location) - if (isset($xmlData['_attributes']['identifier'])) { - $modelId = $xmlData['_attributes']['identifier']; - $this->logger->info('Found model identifier in root attributes', [ - 'identifier' => $modelId - ]); - return $modelId; - } - - // STEP 2: Look for model element with identifier - if (isset($xmlData['model']) && is_array($xmlData['model'])) { - if (isset($xmlData['model']['_attributes']['identifier'])) { - $modelId = $xmlData['model']['_attributes']['identifier']; - $this->logger->info('Found model identifier in model element attributes', [ - 'identifier' => $modelId - ]); - return $modelId; - } - } - - // STEP 3: Look for archimate:model namespace (ArchiMate Tool format) - if (isset($xmlData['archimate:model']) && is_array($xmlData['archimate:model'])) { - if (isset($xmlData['archimate:model']['_attributes']['identifier'])) { - $modelId = $xmlData['archimate:model']['_attributes']['identifier']; - $this->logger->info('Found model identifier in archimate:model namespace', [ - 'identifier' => $modelId - ]); - return $modelId; - } - } - - // STEP 4: Generate fallback identifier if none found - $fallbackId = 'model-' . uniqid() . '-' . time(); - $this->logger->warning('No model identifier found, generating fallback', [ - 'fallback_id' => $fallbackId - ]); - - return $fallbackId; - } - - /** - * Check if a model already exists in the database - * - * @param string $modelIdentifier The model identifier to check - * @return bool True if model exists, false otherwise - */ - private function checkIfModelExists(string $modelIdentifier): bool - { - $this->logger->debug('Checking if model already exists', [ - 'model_identifier' => $modelIdentifier - ]); - - try { - // Get ObjectService to query existing objects - $objectService = $this->getObjectService(); - if (!$objectService) { - $this->logger->warning('ObjectService not available, assuming new model'); - return false; - } - - // Get AMEF configuration for register and schema IDs - $registerId = $this->getAmefRegisterId(); - $schemaId = $this->getAmefSchemaIdForType('model'); - - if (!$registerId || !$schemaId) { - $this->logger->warning('AMEF register or model schema not configured, assuming new model', [ - 'registerId' => $registerId, - 'schemaId' => $schemaId - ]); - return false; - } - - // Query for existing model objects with this identifier - // Use searchObjects with @self structure for proper querying - $query = [ - '@self' => [ - 'register' => $registerId, - 'schema' => $schemaId - ], - 'archimate_id' => $modelIdentifier - ]; - - $existingModels = $objectService->searchObjects($query); - - $exists = !empty($existingModels); - - $this->logger->info('Model existence check completed', [ - 'model_identifier' => $modelIdentifier, - 'exists' => $exists, - 'found_count' => count($existingModels), - 'registerId' => $registerId, - 'schemaId' => $schemaId - ]); - - return $exists; - - } catch (\Exception $e) { - $this->logger->error('Error checking model existence', [ - 'model_identifier' => $modelIdentifier, - 'error' => $e->getMessage() - ]); - // If we can't check, assume new model to avoid data loss - return false; - } - } - - /** - * Create or update a model object in the database - * - * This method establishes the model object that will be used to link all other objects. - * It creates a model object with an archimate_id field that serves as the parent identifier. - * - * @param array $modelMetadata Model metadata from the XML - * @return array Result of the model creation/update operation - */ - private function createOrUpdateModelObject(array $modelMetadata): array - { - try { - $objectService = $this->getObjectService(); - if (!$objectService) { - $this->logger->error('ArchiMateService: ObjectService not available for model object creation'); - return ['success' => false, 'error' => 'ObjectService not available']; - } - - $registerId = $this->getAmefRegisterId(); - $schemaId = $this->getAmefSchemaIdForType('model'); - - if (!$registerId || !$schemaId) { - $this->logger->error('ArchiMateService: AMEF register or model schema not configured', [ - 'registerId' => $registerId, - 'schemaId' => $schemaId - ]); - return ['success' => false, 'error' => 'AMEF register or model schema not configured']; - } - - $modelIdentifier = $modelMetadata['identifier'] ?? ''; - if (empty($modelIdentifier)) { - $this->logger->warning('ArchiMateService: No model identifier found in metadata'); - return ['success' => false, 'error' => 'No model identifier found']; - } - - // Prepare model object data with @self structure for ObjectService::saveObjects - $modelData = [ - '@self' => [ - 'register' => $registerId, - 'schema' => $schemaId, - 'id' => $modelIdentifier // Use the archimate identifier as the UUID - ], - 'archimate_id' => $modelIdentifier, - 'name' => $modelMetadata['name'] ?? '', - 'documentation' => $modelMetadata['documentation'] ?? '', - 'properties' => $modelMetadata['properties'] ?? [], - 'import_time' => date('Y-m-d H:i:s'), - 'import_source' => 'archimate_xml_import' - ]; - - // Save the model object using ObjectService::saveObjects - $savedObjects = $objectService->saveObjects([$modelData]); - - if (empty($savedObjects)) { - $this->logger->error('ArchiMateService: Failed to save model object'); - return ['success' => false, 'error' => 'Failed to save model object']; - } - - $savedModelObject = $savedObjects[0]; - $modelAction = $this->determineObjectAction($savedModelObject); - - $this->logger->info('ArchiMateService: Saved model object', [ - 'model_id' => $modelIdentifier, - 'action' => $modelAction - ]); - - return ['success' => true, 'action' => $modelAction]; - - } catch (\Exception $e) { - $this->logger->error('ArchiMateService: Failed to create/update model object', [ - 'error' => $e->getMessage(), - 'model_metadata' => $modelMetadata - ]); - return ['success' => false, 'error' => $e->getMessage()]; - } - } - /** - * Determine the action taken on an object during save operation - * - * @param array $savedObject The saved object returned from ObjectService::saveObjects - * @return string The action taken: 'created', 'updated', or 'unknown' - */ - private function determineObjectAction(array $savedObject): string - { - // Check if the object was created or updated based on the response - if (isset($savedObject['@self']['id'])) { - // If we have an ID, the object was saved successfully - // We can't easily determine if it was created or updated from the response - // For now, assume it was updated if it has an ID - return 'updated'; - } - - return 'unknown'; - } - /** - * Normalize ArchiMate data structure for storage as JSON blob - * - * This method processes the parsed XML data and prepares it for storage: - * 1. Extracts model metadata (identifier, name, version, etc.) - * 2. Processes each section (elements, relationships, organizations, views, property_definitions) - * 3. Stores complete raw XML data for each item to ensure round-trip fidelity - * 4. Adds model identifier to each item for proper linking - * - * @param array $data Raw parsed XML data from import service - * @param string $modelIdentifier The model identifier for linking items - * @return array Normalized data structure ready for database storage - */ - private function normalizeArchiMateData(array $data, string $modelIdentifier): array - { - $this->logger->info('Normalizing ArchiMate data structure for JSON blob storage', [ - 'model_identifier' => $modelIdentifier - ]); - // Initialize normalized structure with model metadata - $normalized = [ - 'model_metadata' => [], - 'model_identifier' => $modelIdentifier, // Add model identifier for linking - 'elements' => [], - 'relationships' => [], - 'organizations' => [], - 'views' => [], - 'property_definitions' => [] - ]; - // STEP 1: Extract and store model metadata - if (isset($data['_attributes'])) { - $normalized['model_metadata'] = $data['_attributes']; - } - - // Also extract name and documentation from root level - if (isset($data['name'])) { - $normalized['model_metadata']['name'] = $data['name']; - } - if (isset($data['documentation'])) { - $normalized['model_metadata']['documentation'] = $data['documentation']; - } - if (isset($data['properties'])) { - $normalized['model_metadata']['properties'] = $data['properties']; - } - - $this->logger->debug('Extracted model metadata', [ - 'metadata_keys' => array_keys($normalized['model_metadata']), - 'has_name' => isset($normalized['model_metadata']['name']), - 'has_documentation' => isset($normalized['model_metadata']['documentation']) - ]); - // STEP 2: Process each section and store complete raw XML data - // This ensures round-trip fidelity - we can reconstruct the exact XML later - $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; - - $this->logger->debug("Available sections in data", [ - 'available_sections' => array_keys($data), - 'sections_to_process' => $sections - ]); - - // Check for alternative section names that might exist in the XML - $alternativeNames = [ - 'views' => ['views', 'diagrams'], - 'organizations' => ['organizations', 'organisation'], - 'property_definitions' => ['propertyDefinitions', 'property_definitions', 'propertydefinitions'] - ]; - - foreach ($alternativeNames as $section => $alternatives) { - foreach ($alternatives as $altName) { - if (isset($data[$altName])) { - $this->logger->debug("Found alternative section name", [ - 'section' => $section, - 'alternative_name' => $altName, - 'data_type' => gettype($data[$altName]), - 'data_keys' => is_array($data[$altName]) ? array_keys($data[$altName]) : [] - ]); - } - } - } - - foreach ($sections as $section) { - $sectionData = null; - $actualSectionName = null; - - // First try the direct section name - if (isset($data[$section])) { - $sectionData = $data[$section]; - $actualSectionName = $section; - } else { - // Try alternative names for this section - if (isset($alternativeNames[$section])) { - foreach ($alternativeNames[$section] as $altName) { - if (isset($data[$altName])) { - $sectionData = $data[$altName]; - $actualSectionName = $altName; - $this->logger->debug("Using alternative section name", [ - 'section' => $section, - 'alternative_name' => $altName - ]); - break; - } - } - } - } - - if ($sectionData !== null) { - $this->logger->debug("Processing section: {$section}", [ - 'actual_section_name' => $actualSectionName, - 'section_data_type' => gettype($sectionData), - 'section_data_count' => is_array($sectionData) ? count($sectionData) : 'not_array', - 'section_data_keys' => is_array($sectionData) ? array_keys($sectionData) : [] - ]); - - $normalized[$section] = $this->extractSectionData($sectionData, $section, $modelIdentifier); - } else { - $this->logger->debug("Section not found: {$section}"); - } - } - $this->logger->info('Data normalization completed', [ - 'model_identifier' => $modelIdentifier, - 'sections_processed' => $sections, - 'round_trip_fidelity' => 'enabled' - ]); - return $normalized; - } - /** - * Extract data from a specific section with model linking - * - * This method processes each section of the ArchiMate XML and extracts: - * 1. Individual items (elements, relationships, organizations, views, property_definitions) - * 2. Complete raw XML data for each item to ensure round-trip fidelity - * 3. Links each item to the parent model via model_identifier - * - * @param mixed $sectionData Section data from XML parsing - * @param string $sectionName Name of the section being processed - * @param string $modelIdentifier The model identifier for linking items - * @return array Extracted section data with complete XML preservation - */ - private function extractSectionData(mixed $sectionData, string $sectionName, string $modelIdentifier): array - { - $this->logger->debug("Extracting data from section: {$sectionName}", [ - 'section_name' => $sectionName, - 'model_identifier' => $modelIdentifier, - 'data_type' => gettype($sectionData) - ]); - $extracted = []; - - // STEP 1: Handle different data structures (array, object, scalar) - if (is_array($sectionData)) { - $this->logger->debug("Section data structure", [ - 'section' => $sectionName, - 'section_keys' => array_keys($sectionData), - 'section_data_sample' => array_slice($sectionData, 0, 2, true) - ]); - - // STEP 2: Find the actual items within the section - // Could be nested under child tags like , , etc. - $items = $this->findItemsInSection($sectionData, $sectionName); - - $this->logger->debug("Found items in section", [ - 'section' => $sectionName, - 'item_count' => count($items) - ]); - - // STEP 3: Process each item and store complete XML data - foreach ($items as $item) { - $identifier = $this->extractIdentifier($item, $sectionName); - if ($identifier) { - // Store XML data at root level with metadata fields - // This eliminates double JSON serialization and improves performance - $extracted[$identifier] = array_merge( - $item, // XML data at root level - [ - 'identifier' => $identifier, // Unique identifier for the item - 'section' => $sectionName, // Section this item belongs to - 'model_identifier' => $modelIdentifier, // Link to parent model - 'extracted_at' => time() // Timestamp for tracking - ] - ); - - $this->logger->debug("Extracted item", [ - 'identifier' => $identifier, - 'section' => $sectionName, - 'model_identifier' => $modelIdentifier - ]); - } else { - $this->logger->warning("Could not extract identifier from item", [ - 'section' => $sectionName, - 'item_keys' => is_array($item) ? array_keys($item) : ['not_array'] - ]); - } - } - } else { - $this->logger->warning("Section data is not an array", [ - 'section' => $sectionName, - 'data_type' => gettype($sectionData), - 'data_value' => $sectionData - ]); - } - $this->logger->info("Section extraction completed", [ - 'section' => $sectionName, - 'items_extracted' => count($extracted), - 'model_identifier' => $modelIdentifier - ]); - return $extracted; - } /** * Get section structure configuration for XML parsing @@ -854,52 +359,25 @@ private function isAssociativeArray(array $array): bool */ private function findItemsInSection(array $sectionData, string $sectionName): array { - $this->logger->debug("Finding items in section", [ - 'section' => $sectionName, - 'section_data_keys' => is_array($sectionData) ? array_keys($sectionData) : ['not_array'], - 'section_data_type' => gettype($sectionData) - ]); + // OPTIMIZATION: Removed debug logging from section processing $items = []; // Safety check: ensure sectionData is an array if (!is_array($sectionData)) { - $this->logger->warning("Section data is not an array", [ - 'section' => $sectionName, - 'data_type' => gettype($sectionData), - 'data_value' => $sectionData - ]); return []; } // Get section structure configuration from AMEF config $config = $this->getSectionStructureConfig($sectionName); - $this->logger->debug("Section structure config", [ - 'section' => $sectionName, - 'config' => $config - ]); - // Special handling for views with diagrams structure if ($sectionName === 'views') { - $this->logger->debug("Special handling for views section", [ - 'section_keys' => array_keys($sectionData) - ]); // Handle nested structure: if (isset($sectionData['diagrams'])) { - $this->logger->debug("Found diagrams structure in views", [ - 'diagrams_type' => gettype($sectionData['diagrams']), - 'diagrams_keys' => is_array($sectionData['diagrams']) ? array_keys($sectionData['diagrams']) : [] - ]); - if (isset($sectionData['diagrams']['view'])) { $viewArray = $sectionData['diagrams']['view']; - $this->logger->debug("Found view array in diagrams", [ - 'view_array_type' => gettype($viewArray), - 'view_array_count' => is_array($viewArray) ? count($viewArray) : 'not_array', - 'is_single_view' => !isset($viewArray[0]) && isset($viewArray['_attributes']) - ]); // Handle single view vs array of views if (!isset($viewArray[0]) && isset($viewArray['_attributes'])) { @@ -909,18 +387,11 @@ private function findItemsInSection(array $sectionData, string $sectionName): ar // Array of views $items = $viewArray; } - - $this->logger->debug("Processed views from diagrams structure", [ - 'items_count' => count($items) - ]); } } else { // Direct views structure (fallback) if (isset($sectionData['view'])) { $items = $sectionData['view']; - $this->logger->debug("Found direct view structure", [ - 'items_count' => is_array($items) ? count($items) : 'not_array' - ]); } } } else { @@ -939,13 +410,6 @@ private function findItemsInSection(array $sectionData, string $sectionName): ar } if ($pathValid && is_array($currentData)) { - $this->logger->debug("Found data at path", [ - 'section' => $sectionName, - 'path' => $path, - 'data_keys' => array_keys($currentData), - 'data_type' => gettype($currentData) - ]); - // Check if this is a direct array of items or needs further processing if (isset($currentData[0]) || $this->isAssociativeArray($currentData)) { $items = $currentData; @@ -959,11 +423,6 @@ private function findItemsInSection(array $sectionData, string $sectionName): ar if (empty($items)) { foreach ($config['direct_tags'] as $tag) { if (isset($sectionData[$tag])) { - $this->logger->debug("Found direct tag", [ - 'section' => $sectionName, - 'tag' => $tag, - 'data_keys' => is_array($sectionData[$tag]) ? array_keys($sectionData[$tag]) : ['not_array'] - ]); $items = $sectionData[$tag]; break; } @@ -972,38 +431,19 @@ private function findItemsInSection(array $sectionData, string $sectionName): ar // If still no items found, treat the section itself as items if (empty($items)) { - $this->logger->debug("No items found in section, treating section as items", [ - 'section' => $sectionName, - 'section_keys' => is_array($sectionData) ? array_keys($sectionData) : ['not_array'] - ]); $items = [$sectionData]; } // Ensure items is always an array if (!is_array($items)) { - $this->logger->debug("Items is not an array, wrapping in array", [ - 'section' => $sectionName, - 'items_type' => gettype($items) - ]); $items = [$items]; } // If items is an associative array with numeric keys, convert to indexed array if ($this->isAssociativeArray($items)) { - $this->logger->debug("Converting associative array to indexed array", [ - 'section' => $sectionName, - 'items_keys' => array_keys($items) - ]); $items = array_values($items); } - $this->logger->debug("Final items found", [ - 'section' => $sectionName, - 'item_count' => count($items), - 'first_item_keys' => !empty($items) && is_array($items[0]) ? array_keys($items[0]) : [], - 'section_data_keys' => is_array($sectionData) ? array_keys($sectionData) : ['not_array'] - ]); - return $items; } @@ -1016,105 +456,107 @@ private function findItemsInSection(array $sectionData, string $sectionName): ar */ private function extractIdentifier(array $item, string $sectionName = ''): ?string { - $this->logger->debug("Extracting identifier", [ - 'section' => $sectionName, - 'item_keys' => array_keys($item), - 'item_has_attributes' => isset($item['_attributes']), - 'attributes_keys' => isset($item['_attributes']) ? array_keys($item['_attributes']) : [] - ]); - - // Special handling for organizations - they have identifierRef attributes - if ($sectionName === 'organizations') { - // Check for identifierRef in the item itself - if (isset($item['_attributes']['identifierRef'])) { - $identifier = (string) $item['_attributes']['identifierRef']; - $this->logger->debug("Found organization identifierRef", [ - 'section' => $sectionName, - 'identifier' => $identifier - ]); - return $identifier; - } - - // Check for identifierRef in child elements - if (isset($item['item']) && is_array($item['item'])) { - foreach ($item['item'] as $childItem) { - if (isset($childItem['_attributes']['identifierRef'])) { - $identifier = (string) $childItem['_attributes']['identifierRef']; - $this->logger->debug("Found organization identifierRef in child", [ - 'section' => $sectionName, - 'identifier' => $identifier - ]); - return $identifier; - } - } - } + // OPTIMIZATION: Use cached patterns for section-specific identifier extraction + if (isset($this->identifierPatternCache[$sectionName])) { + $patterns = $this->identifierPatternCache[$sectionName]; - // For organizations, if no identifierRef found, try to use the label as identifier - if (isset($item['label'])) { - $label = $item['label']; - if (is_array($label) && isset($label['_value'])) { - $identifier = (string) $label['_value']; - $this->logger->debug("Using organization label as identifier", [ - 'section' => $sectionName, - 'identifier' => $identifier - ]); - return $identifier; - } - if (is_string($label)) { - $this->logger->debug("Using organization label string as identifier", [ - 'section' => $sectionName, - 'identifier' => $label - ]); - return $label; + // Try cached patterns in order of success frequency + foreach ($patterns as $pattern) { + $result = $this->extractIdentifierByPattern($item, $pattern); + if ($result !== null) { + return $result; } } } - // Check various possible identifier locations for other sections - $identifierKeys = ['identifier', 'id', 'name']; + // OPTIMIZATION: Build pattern cache on first encounter of section type + $patterns = $this->buildIdentifierPatternsForSection($sectionName); + $this->identifierPatternCache[$sectionName] = $patterns; - foreach ($identifierKeys as $key) { - if (isset($item['_attributes'][$key])) { - $identifier = (string) $item['_attributes'][$key]; - $this->logger->debug("Found identifier in attributes", [ - 'section' => $sectionName, - 'key' => $key, - 'identifier' => $identifier - ]); - return $identifier; - } - if (isset($item[$key])) { - $value = $item[$key]; - if (is_array($value) && isset($value['_value'])) { - $identifier = (string) $value['_value']; - $this->logger->debug("Found identifier in nested value", [ - 'section' => $sectionName, - 'key' => $key, - 'identifier' => $identifier - ]); - return $identifier; - } - if (is_string($value)) { - $this->logger->debug("Found identifier as direct string", [ - 'section' => $sectionName, - 'key' => $key, - 'identifier' => $value - ]); - return $value; - } + // Try all patterns and return first successful match + foreach ($patterns as $pattern) { + $result = $this->extractIdentifierByPattern($item, $pattern); + if ($result !== null) { + return $result; } } - $this->logger->warning("No identifier found for item", [ - 'section' => $sectionName, - 'item_keys' => array_keys($item), - 'item_has_attributes' => isset($item['_attributes']), - 'attributes_keys' => isset($item['_attributes']) ? array_keys($item['_attributes']) : [] - ]); - return null; } + /** + * OPTIMIZATION: Extract identifier using a specific pattern + * + * @param array $item The item to extract from + * @param array $pattern The extraction pattern ['path' => string[], 'type' => string] + * @return string|null The extracted identifier or null + */ + private function extractIdentifierByPattern(array $item, array $pattern): ?string + { + $path = $pattern['path']; + $type = $pattern['type']; + + // Navigate to the target location + $current = $item; + foreach ($path as $key) { + if (!isset($current[$key])) { + return null; + } + $current = $current[$key]; + } + + // Extract based on type + switch ($type) { + case 'direct': + return is_string($current) ? $current : null; + case 'value': + return is_array($current) && isset($current['_value']) ? (string) $current['_value'] : null; + case 'array_search': + if (is_array($current)) { + foreach ($current as $childItem) { + if (isset($childItem['_attributes']['identifierRef'])) { + return (string) $childItem['_attributes']['identifierRef']; + } + } + } + return null; + default: + return null; + } + } + + /** + * OPTIMIZATION: Build identifier extraction patterns for a section type + * + * @param string $sectionName The section name + * @return array Array of extraction patterns ordered by likelihood of success + */ + private function buildIdentifierPatternsForSection(string $sectionName): array + { + $patterns = []; + + // Special handling for organizations + if ($sectionName === 'organizations') { + $patterns[] = ['path' => ['_attributes', 'identifierRef'], 'type' => 'direct']; + $patterns[] = ['path' => ['item'], 'type' => 'array_search']; + $patterns[] = ['path' => ['label'], 'type' => 'value']; + $patterns[] = ['path' => ['label'], 'type' => 'direct']; + } else { + // Standard patterns for other sections (ordered by frequency in ArchiMate) + $patterns[] = ['path' => ['_attributes', 'identifier'], 'type' => 'direct']; + $patterns[] = ['path' => ['_attributes', 'id'], 'type' => 'direct']; + $patterns[] = ['path' => ['identifier'], 'type' => 'value']; + $patterns[] = ['path' => ['identifier'], 'type' => 'direct']; + $patterns[] = ['path' => ['id'], 'type' => 'value']; + $patterns[] = ['path' => ['id'], 'type' => 'direct']; + $patterns[] = ['path' => ['_attributes', 'name'], 'type' => 'direct']; + $patterns[] = ['path' => ['name'], 'type' => 'value']; + $patterns[] = ['path' => ['name'], 'type' => 'direct']; + } + + return $patterns; + } + /** * Convert normalized data to OpenRegister objects with @self structure * @@ -1145,24 +587,21 @@ private function convertToOpenRegisterObjects(array $normalizedData, string $mod // STEP 2: Convert each section to individual objects $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; + // OPTIMIZATION: Removed excessive debug logging from tight loops + $sectionCounts = []; foreach ($sections as $section) { if (!empty($normalizedData[$section]) && is_array($normalizedData[$section])) { - $this->logger->debug("Converting section: {$section}", [ - 'item_count' => count($normalizedData[$section]), - 'section_keys' => array_keys($normalizedData[$section]) - ]); - + $sectionCounts[$section] = count($normalizedData[$section]); foreach ($normalizedData[$section] as $identifier => $data) { $objects[] = $this->createSectionObject($section, $identifier, $data, $modelIdentifier); } } else { - $this->logger->debug("Section empty or not found: {$section}", [ - 'section_exists' => isset($normalizedData[$section]), - 'section_type' => isset($normalizedData[$section]) ? gettype($normalizedData[$section]) : 'not_set', - 'section_empty' => isset($normalizedData[$section]) ? empty($normalizedData[$section]) : 'not_set' - ]); + $sectionCounts[$section] = 0; } } + + // Single consolidated log entry + $this->logger->debug('Sections processed', $sectionCounts); $this->logger->info('Conversion to OpenRegister objects completed', [ 'model_identifier' => $modelIdentifier, @@ -1182,19 +621,9 @@ private function convertToOpenRegisterObjects(array $normalizedData, string $mod */ private function createModelObject(array $metadata, string $modelIdentifier): array { - // Get AMEF configuration for register and schema IDs - $registerId = $this->getAmefRegisterId(); - $schemaId = $this->getAmefSchemaIdForType('model'); - - if (!$registerId || !$schemaId) { - $this->logger->warning('AMEF register or model schema not configured, using fallback values', [ - 'registerId' => $registerId, - 'schemaId' => $schemaId - ]); - // Fallback to hardcoded values if AMEF config is not available - $registerId = $registerId ?: 15; // Default AMEF register ID - $schemaId = $schemaId ?: 67; // Default model schema ID - } + // OPTIMIZATION: Use cached configuration values + $registerId = $this->cachedConfig['registerId']; + $schemaId = $this->cachedConfig['schemaIds']['model']; // Create object with @self structure and metadata at root level (no JSON serialization) $object = [ @@ -1202,10 +631,7 @@ private function createModelObject(array $metadata, string $modelIdentifier): ar 'register' => $registerId, 'schema' => $schemaId, 'id' => $metadata['identifier'] ?? uniqid('model_'), - 'owner' => $this->getCurrentUserId(), - 'organisation' => $this->getCurrentOrganisation(), - 'created' => date('Y-m-d H:i:s'), - 'updated' => date('Y-m-d H:i:s') + 'published' => date('Y-m-d\TH:i:s\Z') ], 'identifier' => $metadata['identifier'] ?? '', 'section' => 'model', @@ -1227,20 +653,9 @@ private function createModelObject(array $metadata, string $modelIdentifier): ar */ private function createSectionObject(string $section, string $identifier, array $data, string $modelIdentifier): array { - // Get AMEF configuration for register and schema IDs - $registerId = $this->getAmefRegisterId(); - $schemaId = $this->getAmefSchemaIdForType($section); - - if (!$registerId || !$schemaId) { - $this->logger->warning('AMEF register or schema not configured for section, using fallback values', [ - 'section' => $section, - 'registerId' => $registerId, - 'schemaId' => $schemaId - ]); - // Fallback to hardcoded values if AMEF config is not available - $registerId = $registerId ?: 15; // Default AMEF register ID - $schemaId = $schemaId ?: $this->getSchemaIdForSection($section); // Fallback to hardcoded schema - } + // OPTIMIZATION: Use cached configuration values + $registerId = $this->cachedConfig['registerId']; + $schemaId = $this->cachedConfig['schemaIds'][$section] ?? $this->getSchemaIdForSection($section); // Create object with @self structure and XML data at root level (no double serialization) $object = [ @@ -1248,13 +663,32 @@ private function createSectionObject(string $section, string $identifier, array 'register' => $registerId, 'schema' => $schemaId, 'id' => $identifier, - 'owner' => $this->getCurrentUserId(), - 'organisation' => $this->getCurrentOrganisation(), - 'created' => date('Y-m-d H:i:s'), - 'updated' => date('Y-m-d H:i:s') + 'published' => date('Y-m-d\TH:i:s\Z') ] ]; + // Set slug: first try from _slug field, then from Object ID property, then extract from identifier + $slug = null; + + // Check if there's a temporary slug to move to @self structure + if (isset($data['_slug'])) { + $slug = $data['_slug']; + unset($data['_slug']); // Remove the temporary field + } + // Check if we have "Object ID" property directly + elseif (isset($data['Object ID'])) { + $slug = $data['Object ID']; + } + // Fallback: extract from identifier (remove "id-" prefix if present) + elseif ($identifier && str_starts_with($identifier, 'id-')) { + $slug = substr($identifier, 3); // Remove "id-" prefix + } + + // Set the slug if we found one + if ($slug) { + $object['@self']['slug'] = $slug; + } + // Merge XML data directly at root level (data already contains identifier, section, model_identifier) return array_merge($object, $data); } @@ -1267,36 +701,236 @@ private function createSectionObject(string $section, string $identifier, array */ private function saveObjectsToDatabase(array $objects): array { + $saveStartTime = microtime(true); + + $serviceInitStartTime = microtime(true); $objectService = $this->getObjectService(); if (!$objectService) { throw new \RuntimeException('ObjectService not available'); } + $serviceInitTime = microtime(true) - $serviceInitStartTime; + + // ENHANCEMENT: Process GEMMA Referentiecomponent-Standaard relationships before saving + $gemmaProcessingStartTime = microtime(true); + $objects = $this->processGemmaReferenceComponentStandards($objects); + $gemmaProcessingTime = microtime(true) - $gemmaProcessingStartTime; + + $this->logger->info('Saving objects to database using parallel batch processing', [ + 'count' => count($objects), + 'batch_size' => self::PERFORMANCE_OPTIMIZATIONS['batch_size'], + 'parallel_batches' => self::PERFORMANCE_OPTIMIZATIONS['parallel_batches'], + 'service_init_time' => round($serviceInitTime, 3), + 'gemma_processing_time' => round($gemmaProcessingTime, 3) + ]); - $this->logger->info('Saving objects to database using ObjectService::saveObjects', [ - 'count' => count($objects) + // OPTIMIZATION: Use cached register ID + $registerId = $this->cachedConfig['registerId']; + + // PERFORMANCE OPTIMIZATION: Use parallel batch processing for large datasets + $batchProcessingStartTime = microtime(true); + if (self::PERFORMANCE_OPTIMIZATIONS['parallel_processing'] && count($objects) > self::PERFORMANCE_OPTIMIZATIONS['batch_size']) { + $result = $this->saveObjectsInParallelBatches($objects, $objectService, $registerId); + } else { + // Fallback to single batch for small datasets + $result = $this->saveObjectsInSingleBatch($objects, $objectService, $registerId); + } + $batchProcessingTime = microtime(true) - $batchProcessingStartTime; + + $totalSaveTime = microtime(true) - $saveStartTime; + + $this->logger->info('Database save operation completed', [ + 'total_save_time' => round($totalSaveTime, 3), + 'service_init_time' => round($serviceInitTime, 3), + 'gemma_processing_time' => round($gemmaProcessingTime, 3), + 'batch_processing_time' => round($batchProcessingTime, 3), + 'objects_saved' => count($result), + 'save_rate_objects_per_second' => round(count($objects) / max($totalSaveTime, 0.001), 1) ]); - // Get AMEF configuration for register ID - $registerId = $this->getAmefRegisterId(); + // Store timing breakdown for performance metrics + $this->lastSaveTimingBreakdown = [ + 'total_save_seconds' => round($totalSaveTime, 3), + 'service_init_seconds' => round($serviceInitTime, 3), + 'gemma_processing_seconds' => round($gemmaProcessingTime, 3), + 'batch_processing_seconds' => round($batchProcessingTime, 3), + 'objects_saved' => count($result), + 'save_rate_objects_per_second' => round(count($objects) / max($totalSaveTime, 0.001), 1) + ]; + + return $result; + } + + /** + * Save objects in parallel batches for maximum performance + * + * @param array $objects Array of objects to save + * @param ObjectService $objectService ObjectService instance + * @param int $registerId Register ID + * @return array Array of saved objects + */ + private function saveObjectsInParallelBatches(array $objects, ObjectService $objectService, int $registerId): array + { + $batchSize = self::PERFORMANCE_OPTIMIZATIONS['batch_size']; + $parallelBatches = self::PERFORMANCE_OPTIMIZATIONS['parallel_batches']; - if (!$registerId) { - $this->logger->warning('AMEF register not configured, using fallback value', [ - 'registerId' => $registerId - ]); - // Fallback to hardcoded value if AMEF config is not available - $registerId = $registerId ?: 15; // Default AMEF register ID + // INTELLIGENT BATCH SIZING: Create size-aware batches instead of fixed-size chunks + $chunks = $this->createIntelligentBatches($objects); + $totalChunks = count($chunks); + + $this->logger->info('Starting intelligent batch processing', [ + 'total_objects_to_save' => count($objects), + 'intelligent_batches_created' => $totalChunks, + 'batch_sizes' => array_map('count', $chunks), + 'batching_method' => 'size_aware_intelligent', + 'mysql_packet_limit_safe' => true + ]); + + $allResults = []; + $processedChunks = 0; + + // Accumulate statistics from all chunks + $aggregatedStats = [ + 'saved' => [], + 'updated' => [], + 'skipped' => [], + 'invalid' => [] + ]; + + // Process chunks sequentially but with larger batch sizes for better performance + foreach ($chunks as $chunkIndex => $chunk) { + // OPTIMIZATION: Removed debug logging from chunk processing loop + + try { + $saveResult = $objectService->saveObjects( + objects: $chunk, + register: $registerId, + schema: null, + rbac: self::PERFORMANCE_OPTIMIZATIONS['disable_rbac'] ? false : true, + multi: self::PERFORMANCE_OPTIMIZATIONS['use_multi'], + validation: !self::PERFORMANCE_OPTIMIZATIONS['disable_validation'], + events: !self::PERFORMANCE_OPTIMIZATIONS['disable_events'] + ); + + // Accumulate statistics from this chunk + $aggregatedStats['saved'] = array_merge($aggregatedStats['saved'], $saveResult['saved'] ?? []); + $aggregatedStats['updated'] = array_merge($aggregatedStats['updated'], $saveResult['updated'] ?? []); + $aggregatedStats['skipped'] = array_merge($aggregatedStats['skipped'], $saveResult['skipped'] ?? []); + $aggregatedStats['invalid'] = array_merge($aggregatedStats['invalid'], $saveResult['invalid'] ?? []); + + $savedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [] + ); + + $allResults = array_merge($allResults, $savedObjects); + + $processedChunks++; + $this->logger->info('Processed chunk', [ + 'processed_chunks' => $processedChunks, + 'total_chunks' => $totalChunks, + 'progress_percent' => round(($processedChunks / $totalChunks) * 100, 1), + 'chunk_saved' => count($saveResult['saved'] ?? []), + 'chunk_updated' => count($saveResult['updated'] ?? []), + 'chunk_skipped' => count($saveResult['skipped'] ?? []), + 'chunk_invalid' => count($saveResult['invalid'] ?? []) + ]); + + } catch (\Exception $e) { + $this->logger->error('Error processing chunk', [ + 'chunk_index' => $chunkIndex, + 'error' => $e->getMessage() + ]); + // Continue with other chunks + } + + // Memory cleanup between chunks + if (self::PERFORMANCE_OPTIMIZATIONS['memory_cleanup']) { + $this->cleanupMemory(); + } } + + // Store the aggregated result for statistics calculation + $this->lastSaveResult = $aggregatedStats; + + $this->logger->info('Optimized batch processing completed', [ + 'total_objects_processed' => count($allResults), + 'total_chunks_processed' => $totalChunks, + 'aggregated_saved' => count($aggregatedStats['saved']), + 'aggregated_updated' => count($aggregatedStats['updated']), + 'aggregated_skipped' => count($aggregatedStats['skipped']), + 'aggregated_invalid' => count($aggregatedStats['invalid']) + ]); + + return $allResults; + } - // Save objects using ObjectService::saveObjects with proper @self structure - $savedObjects = $objectService->saveObjects( + /** + * Save objects in a single batch (fallback method) + * + * @param array $objects Array of objects to save + * @param ObjectService $objectService ObjectService instance + * @param int $registerId Register ID + * @return array Array of saved objects + */ + private function saveObjectsInSingleBatch(array $objects, ObjectService $objectService, int $registerId): array + { + $this->logger->info('Using single batch processing', [ + 'count' => count($objects) + ]); + + $saveResult = $objectService->saveObjects( objects: $objects, - register: $registerId + register: $registerId, + schema: null, + rbac: self::PERFORMANCE_OPTIMIZATIONS['disable_rbac'] ? false : true, + multi: self::PERFORMANCE_OPTIMIZATIONS['use_multi'], + validation: !self::PERFORMANCE_OPTIMIZATIONS['disable_validation'], + events: !self::PERFORMANCE_OPTIMIZATIONS['disable_events'] + ); + + // Store the save result for later access to statistics + $this->lastSaveResult = $saveResult; + + // Extract saved objects from the new structured return format + $savedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [] ); + // Log detailed results including validation errors $this->logger->info('Objects saved successfully', [ - 'saved_count' => count($savedObjects) + 'saved_count' => count($saveResult['saved'] ?? []), + 'updated_count' => count($saveResult['updated'] ?? []), + 'unchanged_count' => count($saveResult['skipped'] ?? []), + 'invalid_count' => count($saveResult['invalid'] ?? []), + 'error_count' => count($saveResult['errors'] ?? []), + 'total_processed' => $saveResult['statistics']['totalProcessed'] ?? 0 ]); + // Log any validation errors for debugging + if (!empty($saveResult['invalid'])) { + foreach ($saveResult['invalid'] as $invalidItem) { + $this->logger->warning('Object failed validation during import', [ + 'object_id' => $invalidItem['object']['@self']['id'] ?? 'unknown', + 'error' => $invalidItem['error'] ?? 'Unknown validation error', + 'type' => $invalidItem['type'] ?? 'ValidationException' + ]); + } + } + + // Log details about skipped objects if any + if (!empty($saveResult['skipped'])) { + $this->logger->info('Objects skipped during import (no changes detected)', [ + 'skipped_count' => count($saveResult['skipped']), + 'sample_skipped_ids' => array_slice( + array_map(fn($obj) => $obj->getUuid() ?? 'unknown', $saveResult['skipped']), + 0, + 5 + ) + ]); + } + + // Return the combined saved and updated objects (maintaining backward compatibility) return $savedObjects; } @@ -1328,46 +962,140 @@ private function getObjectService(): ?ObjectService } /** - * Get current user ID + * Initialize cached configuration values for performance optimization * - * @return string|null Current user ID or null if not authenticated + * @return void */ - private function getCurrentUserId(): ?string + private function initializeCache(): void { - $user = $this->userSession->getUser(); - return $user ? $user->getUID() : null; + if ($this->cachedConfig !== null) { + return; // Already cached + } + + // Get the AMEF register ID from configuration - throw error if missing + $amefConfig = $this->settingsService->getAmefConfig(); + if (!isset($amefConfig['register']) || empty($amefConfig['register'])) { + throw new \InvalidArgumentException('AMEF register ID is not configured. Please configure the AMEF register via the admin interface.'); + } + $registerId = (int)$amefConfig['register']; + + $this->cachedConfig = [ + 'registerId' => $registerId, // Use AMEF register ID directly + 'schemaIds' => [ + 'model' => $this->settingsService->getSchemaIdForObjectType('model'), + 'element' => $this->settingsService->getSchemaIdForObjectType('element'), + 'relationship' => $this->settingsService->getSchemaIdForObjectType('relationship'), + 'view' => $this->settingsService->getSchemaIdForObjectType('view'), + 'organization' => $this->settingsService->getSchemaIdForObjectType('organization'), + 'property_definition' => $this->settingsService->getSchemaIdForObjectType('property_definition') + // NOTE: 'property' removed - properties are never root-level AMEF objects, only nested within other elements + ] + ]; + + $this->logger->debug('ArchiMateService: Cache initialized', [ + 'registerId' => $this->cachedConfig['registerId'], + 'schemaIds' => $this->cachedConfig['schemaIds'] + ]); + + // Validate that all required schema IDs are configured + $this->validateRequiredConfiguration(); } /** - * Get current organisation - * - * @return string Default organisation + * Validates that all required configuration is present before import + * + * @throws \RuntimeException If required configuration is missing */ - private function getCurrentOrganisation(): string + private function validateRequiredConfiguration(): void { - return 'default'; + $missingConfig = []; + $requiredSchemaTypes = ['model', 'element', 'relationship', 'view', 'organization', 'property']; + + // Check register ID + if (empty($this->cachedConfig['registerId'])) { + $missingConfig[] = 'AMEF Register ID (amef_register)'; + } + + // Check all required schema IDs + foreach ($requiredSchemaTypes as $schemaType) { + $schemaId = $this->cachedConfig['schemaIds'][$schemaType] ?? null; + if ($schemaId === null) { + $configKey = "amef_{$schemaType}_schema"; + $missingConfig[] = "Schema ID for {$schemaType} ({$configKey})"; + } + } + + // If any configuration is missing, throw detailed error + if (!empty($missingConfig)) { + $this->logger->error('ArchiMateService: Missing required configuration', [ + 'missing_config' => $missingConfig, + 'current_config' => [ + 'registerId' => $this->cachedConfig['registerId'], + 'schemaIds' => $this->cachedConfig['schemaIds'] + ] + ]); + + $errorMessage = 'ArchiMate import cannot proceed due to missing configuration:' . "\n\n"; + $errorMessage .= "Missing configuration:\n"; + foreach ($missingConfig as $item) { + $errorMessage .= "- {$item}\n"; + } + $errorMessage .= "\nPlease configure the AMEF register and all required schema IDs in the SoftwareCatalog settings before importing."; + $errorMessage .= "\nYou can use the auto-configuration feature or set them manually via the admin interface."; + + throw new \RuntimeException($errorMessage); + } + + $this->logger->info('ArchiMateService: Configuration validation passed', [ + 'registerId' => $this->cachedConfig['registerId'], + 'configuredSchemas' => count(array_filter($this->cachedConfig['schemaIds'])) + ]); } /** - * Get ArchiMate register ID + * Log current memory usage for performance monitoring * - * @return int Register ID + * @param string $stage Description of the current processing stage + * @return void */ - private function getArchiMateRegisterId(): int + private function logMemoryUsage(string $stage): void { - return (int) ($this->config->getValueString('softwarecatalog', 'archimate_register_id', '100')); + // Check if debug logging is available (Nextcloud logger doesn't have isDebug method) + $memoryUsage = memory_get_usage(true); + $memoryPeak = memory_get_peak_usage(true); + $memoryLimit = ini_get('memory_limit'); + + $this->logger->debug("Memory usage at: {$stage}", [ + 'current_mb' => round($memoryUsage / 1024 / 1024, 2), + 'peak_mb' => round($memoryPeak / 1024 / 1024, 2), + 'limit' => $memoryLimit + ]); } /** - * Get ArchiMate model schema ID + * Clean up memory by forcing garbage collection * - * @return int Schema ID + * @return void */ - private function getArchiMateModelSchemaId(): int + private function cleanupMemory(): void { - return (int) ($this->config->getValueString('softwarecatalog', 'archimate_model_schema_id', '100')); + if (function_exists('gc_collect_cycles')) { + $cycles = gc_collect_cycles(); + // Use PSR-3 standard logging instead of isDebug() check + $this->logger->debug('Garbage collection completed', [ + 'cycles_collected' => $cycles + ]); + } } + + + /** + * NOTE: Removed deprecated methods getArchiMateRegisterId() and getArchiMateModelSchemaId() + * that had hardcoded fallbacks. All configuration is now retrieved via SettingsService + * through the initializeCache() method and proper AMEF configuration. + */ + /** * Get schema ID for a section * @@ -1376,15 +1104,24 @@ private function getArchiMateModelSchemaId(): int */ private function getSchemaIdForSection(string $section): int { - $schemaIds = [ - 'elements' => 101, - 'relationships' => 102, - 'views' => 103, - 'organizations' => 104, - 'property_definitions' => 105 + // Map section names to object types for SettingsService + $objectTypeMapping = [ + 'elements' => 'element', + 'relationships' => 'relationship', + 'views' => 'view', + 'organizations' => 'organization', + 'property_definitions' => 'property_definition' ]; - - return $schemaIds[$section] ?? 100; + + $objectType = $objectTypeMapping[$section] ?? $section; + $schemaId = $this->settingsService->getSchemaIdForObjectType($objectType); + + // Ensure schema ID is configured - no hardcoded fallbacks + if ($schemaId === null) { + throw new \RuntimeException("Schema ID for section '{$section}' is not configured. Please configure all AMEF schema IDs via the admin interface. Expected object type: '{$objectType}'"); + } + + return $schemaId; } /** @@ -1613,8 +1350,8 @@ public function getArchiMateStatus(): array */ private function getAmefRegisterId(): ?int { - // Retrieve AMEF configuration - $amefConfig = $this->getAmefConfig(); + // Retrieve AMEF configuration (use SettingsService for consistency) + $amefConfig = $this->settingsService->getAmefConfig(); // Try JSON config keys first: support both 'register_id' and 'register' $rawRegisterId = $amefConfig['register_id'] @@ -1637,72 +1374,18 @@ private function getAmefRegisterId(): ?int } /** - * Get AMEF schema ID for a specific ArchiMate type + * Get AMEF schema ID for a specific ArchiMate type via SettingsService * - * This method retrieves the schema ID for a given ArchiMate type from the AMEF configuration. - * It looks for the schema ID using the pattern '{type}_schema' in the configuration. + * This method retrieves the schema ID for a given ArchiMate type from SettingsService. * * @param string $archiMateType The ArchiMate type (e.g., 'element', 'organization', 'relationship') * @return int|null The schema ID for the given type or null if not configured */ private function getAmefSchemaIdForType(string $archiMateType): ?int { - // Get AMEF configuration - $amefConfig = $this->getAmefConfig(); - - // Normalize plural → singular and handle the actual config structure - $typeMapping = [ - 'elements' => 'element', - 'organizations' => 'organization', - // Accept both 'relationships' (AMEF wording) and UI term 'relation' - 'relationships' => 'relation', - 'views' => 'view', - 'models' => 'model', - 'properties' => 'property', - // Accept both underscored and dashed naming conventions - 'property_definitions' => 'property-definition' - ]; - $normalizedType = $typeMapping[$archiMateType] ?? $archiMateType; - - // Candidate keys: match the actual config structure - $schemaKeyCandidatesByType = [ - 'element' => ['element_schema'], - 'organization' => ['organization_schema'], - 'relationship' => ['relation_schema'], - 'view' => ['view_schema'], - 'model' => ['model_schema'], - 'property' => ['property_schema'], - 'property_definition' => ['property-definition_schema'] - ]; - - $candidates = $schemaKeyCandidatesByType[$normalizedType] ?? [$normalizedType . '_schema']; - - // Try JSON config with the actual keys - foreach ($candidates as $key) { - if (array_key_exists($key, $amefConfig)) { - $raw = $amefConfig[$key]; - if ($raw !== '' && $raw !== null && is_numeric((string) $raw)) { - $id = (int) $raw; - if ($id > 0) { - return $id; - } - } - } - } - - // Fallback to legacy individual app config keys if not present in JSON - foreach ($candidates as $key) { - $raw = $this->config->getValueString('softwarecatalog', 'amef_' . $key, '') - ?: $this->config->getValueString('softwarecatalog', $key, ''); - if ($raw !== '' && is_numeric((string) $raw)) { - $id = (int) $raw; - if ($id > 0) { - return $id; - } - } - } - - return null; + // Use SettingsService to get schema ID + $schemaId = $this->settingsService->getSchemaIdForObjectType($archiMateType); + return $schemaId; } /** @@ -1798,8 +1481,8 @@ private function getObjectsWithPagination(string $schemaType, array $query = []) return []; } - $registerId = $this->getAmefRegisterId(); - $schemaId = $this->getAmefSchemaIdForType($schemaType); + $registerId = $this->settingsService->getRegisterIdForObjectType($schemaType); + $schemaId = $this->settingsService->getSchemaIdForObjectType($schemaType); if (!$registerId || !$schemaId) { $this->logger->error("ArchiMateService: AMEF register or {$schemaType} schema not configured", [ @@ -1896,6 +1579,155 @@ public function isOperationInProgress(): bool return $this->isImportInProgress() || $this->isExportInProgress(); } + /** + * Create intelligent batches based on object size to prevent MySQL packet size issues + * + * This method analyzes object sizes and creates batches that stay under the MySQL + * max_allowed_packet limit while maintaining reasonable performance. + * + * @param array $objects Array of objects to batch + * @return array Array of batches, each containing objects that fit within size limits + */ + private function createIntelligentBatches(array $objects): array + { + $maxBatchSizeBytes = self::PERFORMANCE_OPTIMIZATIONS['max_batch_size_bytes']; + $minBatchSize = self::PERFORMANCE_OPTIMIZATIONS['min_batch_size']; + $sampleSize = self::PERFORMANCE_OPTIMIZATIONS['size_estimation_sample']; + + if (empty($objects)) { + return []; + } + + // Estimate average object size by sampling + $avgObjectSize = $this->estimateAverageObjectSize($objects, $sampleSize); + + // Calculate optimal batch size based on object size + $optimalBatchSize = max($minBatchSize, intval($maxBatchSizeBytes / $avgObjectSize)); + + $this->logger->info('Intelligent batch sizing analysis', [ + 'total_objects' => count($objects), + 'estimated_avg_object_size_bytes' => $avgObjectSize, + 'max_batch_size_bytes' => $maxBatchSizeBytes, + 'calculated_optimal_batch_size' => $optimalBatchSize, + 'min_batch_size_enforced' => $minBatchSize + ]); + + // Create batches with size awareness + $batches = []; + $currentBatch = []; + $currentBatchSize = 0; + + foreach ($objects as $object) { + $objectSize = $this->estimateObjectSize($object); + + // Check if adding this object would exceed the batch size limit + if (!empty($currentBatch) && ($currentBatchSize + $objectSize) > $maxBatchSizeBytes) { + // Current batch is full, save it and start a new one + $batches[] = $currentBatch; + $currentBatch = [$object]; + $currentBatchSize = $objectSize; + } else { + // Add object to current batch + $currentBatch[] = $object; + $currentBatchSize += $objectSize; + } + + // Safety check: if a single object is larger than max batch size, + // create a batch with just that object + if (count($currentBatch) === 1 && $objectSize > $maxBatchSizeBytes) { + $this->logger->warning('Very large object detected, creating single-object batch', [ + 'object_id' => $object['@self']['id'] ?? 'unknown', + 'object_size_bytes' => $objectSize, + 'max_batch_size_bytes' => $maxBatchSizeBytes + ]); + $batches[] = $currentBatch; + $currentBatch = []; + $currentBatchSize = 0; + } + } + + // Add the last batch if it has objects + if (!empty($currentBatch)) { + $batches[] = $currentBatch; + } + + $this->logger->info('Intelligent batching completed', [ + 'total_objects' => count($objects), + 'total_batches_created' => count($batches), + 'batch_sizes' => array_map('count', $batches), + 'estimated_batch_sizes_bytes' => array_map(fn($batch) => array_sum(array_map([$this, 'estimateObjectSize'], $batch)), $batches) + ]); + + return $batches; + } + + /** + * Estimate the average size of objects by sampling + * + * @param array $objects Array of objects to sample + * @param int $sampleSize Number of objects to sample for size estimation + * @return int Estimated average object size in bytes + */ + private function estimateAverageObjectSize(array $objects, int $sampleSize): int + { + $totalObjects = count($objects); + if ($totalObjects === 0) { + return 1000; // Default fallback size + } + + // Sample evenly distributed objects + $sampleIndices = []; + if ($totalObjects <= $sampleSize) { + // Use all objects if we have fewer than sample size + $sampleIndices = range(0, $totalObjects - 1); + } else { + // Sample evenly across the array + $step = max(1, intval($totalObjects / $sampleSize)); + for ($i = 0; $i < $totalObjects; $i += $step) { + $sampleIndices[] = $i; + if (count($sampleIndices) >= $sampleSize) { + break; + } + } + } + + // Calculate sizes of sampled objects + $totalSampleSize = 0; + foreach ($sampleIndices as $index) { + $totalSampleSize += $this->estimateObjectSize($objects[$index]); + } + + $averageSize = intval($totalSampleSize / count($sampleIndices)); + + $this->logger->debug('Object size estimation completed', [ + 'total_objects' => $totalObjects, + 'sampled_objects' => count($sampleIndices), + 'total_sample_size_bytes' => $totalSampleSize, + 'estimated_average_size_bytes' => $averageSize + ]); + + return max(1000, $averageSize); // Minimum 1KB per object + } + + /** + * Estimate the serialized size of an object for batching purposes + * + * @param array $object The object to estimate size for + * @return int Estimated size in bytes + */ + private function estimateObjectSize(array $object): int + { + // Quick estimation based on JSON serialization + // This includes overhead for SQL parameters and structure + $jsonSize = strlen(json_encode($object)); + + // Add overhead for SQL INSERT statement structure + // Each object becomes multiple parameters in a bulk INSERT + $sqlOverhead = 500; // Estimated overhead per object in SQL + + return $jsonSize + $sqlOverhead; + } + /** * Calculate detailed object statistics for import operations * @@ -1914,17 +1746,92 @@ private function calculateObjectStatistics(array $normalizedData, array $savedOb 'property_definitions' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => []] ]; - // Count objects by type from normalized data - $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; - foreach ($sections as $section) { - if (isset($normalizedData[$section])) { - $count = count($normalizedData[$section]); - // Assume all objects were created (we can refine this later with actual save results) - $statistics[$section]['created'] = $count; - } - } - - // Calculate summary totals + // If we have access to the actual save results from ObjectService, use those + if ($this->lastSaveResult !== null) { + $saveResult = $this->lastSaveResult; + + // Count objects by section type from the actual processed objects + $allProcessedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [], + $saveResult['skipped'] ?? [], + // For invalid objects, extract the original object from the error structure + array_map(fn($item) => $item['object'] ?? [], $saveResult['invalid'] ?? []) + ); + + foreach ($allProcessedObjects as $object) { + // Convert ObjectEntity to array if needed + if (is_object($object) && method_exists($object, 'jsonSerialize')) { + $object = $object->jsonSerialize(); + } + + $sectionType = $object['section'] ?? 'elements'; // Default to elements if section not found + + // Map section types to statistics keys + $sectionKey = match($sectionType) { + 'elements' => 'elements', + 'relationships' => 'relationships', + 'organizations' => 'organizations', + 'views' => 'views', + 'property_definitions' => 'property_definitions', + default => 'elements' // Default fallback + }; + + if (!isset($statistics[$sectionKey])) { + continue; // Skip unknown section types + } + + // Determine if this object was created, updated, or had errors + $objectId = $object['@self']['id'] ?? $object['identifier'] ?? null; + + // Check if this object is in the saved (created) list + $wasCreated = !empty(array_filter($saveResult['saved'] ?? [], + fn($saved) => ($saved->getUuid() === $objectId))); + + // Check if this object is in the updated list + $wasUpdated = !empty(array_filter($saveResult['updated'] ?? [], + fn($updated) => ($updated->getUuid() === $objectId))); + + // Check if this object was skipped (no changes) + $wasSkipped = !empty(array_filter($saveResult['skipped'] ?? [], + fn($skipped) => ($skipped->getUuid() === $objectId))); + + // Check if this object had validation errors + $hasErrors = !empty(array_filter($saveResult['invalid'] ?? [], + fn($invalid) => (($invalid['object']['@self']['id'] ?? null) === $objectId))); + + if ($wasCreated) { + $statistics[$sectionKey]['created']++; + } elseif ($wasUpdated) { + $statistics[$sectionKey]['updated']++; + } elseif ($wasSkipped) { + $statistics[$sectionKey]['skipped']++; + } elseif ($hasErrors) { + // Add to errors array for this section + $errorInfo = array_filter($saveResult['invalid'] ?? [], + fn($invalid) => (($invalid['object']['@self']['id'] ?? null) === $objectId)); + + if (!empty($errorInfo)) { + $statistics[$sectionKey]['errors'][] = array_values($errorInfo)[0]['error'] ?? 'Unknown validation error'; + } + } else { + // This shouldn't happen, but leave as fallback + $statistics[$sectionKey]['skipped']++; + } + } + } else { + // Fallback to old method if no save result is available + $sections = ['elements', 'relationships', 'organizations', 'views', 'property_definitions']; + foreach ($sections as $section) { + if (isset($normalizedData[$section])) { + $count = count($normalizedData[$section]); + // Assume all objects were created (legacy behavior) + $statistics[$section]['created'] = $count; + } + } + } + + // Calculate summary totals from actual statistics $summary = [ 'total_objects_created' => 0, 'total_objects_updated' => 0, @@ -1934,10 +1841,12 @@ private function calculateObjectStatistics(array $normalizedData, array $savedOb ]; foreach ($statistics as $section => $sectionStats) { - $summary['total_objects_created'] += $sectionStats['created']; - $summary['total_objects_updated'] += $sectionStats['updated']; - $summary['total_objects_skipped'] += $sectionStats['skipped']; - $summary['total_errors'] += count($sectionStats['errors']); + if ($section !== 'summary') { // Skip summary section itself + $summary['total_objects_created'] += $sectionStats['created']; + $summary['total_objects_updated'] += $sectionStats['updated']; + $summary['total_objects_skipped'] += $sectionStats['skipped']; + $summary['total_errors'] += count($sectionStats['errors']); + } } $statistics['summary'] = $summary; @@ -1945,5 +1854,750 @@ private function calculateObjectStatistics(array $normalizedData, array $savedOb return $statistics; } + /** + * Extract propertyDefinitions from the parsed XML and build a map + * + * @param array $data Parsed XML data + * @return array Map of propertyDefinitionRef => property name + */ + private function extractPropertyDefinitionMap(array $data): array + { + // OPTIMIZATION: Return cached property definition map if available + if ($this->propertyDefinitionMapCache !== null) { + return $this->propertyDefinitionMapCache; + } + + $map = []; + // Find propertyDefinitions section (handle possible alternative names) + $propertyDefs = null; + if (isset($data['propertyDefinitions'])) { + $propertyDefs = $data['propertyDefinitions']; + } elseif (isset($data['property_definitions'])) { + $propertyDefs = $data['property_definitions']; + } elseif (isset($data['propertyDefinitions'])) { + $propertyDefs = $data['propertyDefinitions']; + } + if ($propertyDefs && isset($propertyDefs['propertyDefinition'])) { + $defs = $propertyDefs['propertyDefinition']; + if (isset($defs[0])) { + // Array of propertyDefinition + foreach ($defs as $def) { + if (isset($def['_attributes']['identifier']) && isset($def['name'])) { + $map[$def['_attributes']['identifier']] = is_array($def['name']) && isset($def['name']['_value']) ? $def['name']['_value'] : $def['name']; + } + } + } elseif (isset($defs['_attributes']['identifier']) && isset($defs['name'])) { + // Single propertyDefinition + $map[$defs['_attributes']['identifier']] = is_array($defs['name']) && isset($defs['name']['_value']) ? $defs['name']['_value'] : $defs['name']; + } + } + + // OPTIMIZATION: Cache the result for subsequent calls during the same import + $this->propertyDefinitionMapCache = $map; + + return $map; + } + + /** + * Transform ArchiMate XML data to objects array in batch (OpenRegister pattern) + * + * This method follows the same pattern as OpenRegister CSV import: + * - Parse ALL sections at once + * - Create objects directly without intermediate normalization + * - Use cached configuration values + * - Minimize object copying and complex transformations + * + * @param array $xmlData Parsed XML data + * @param string $modelIdentifier Model identifier + * @return array Array of objects ready for saveObjects() + */ + private function transformArchiMateXmlToObjectsBatch(array $xmlData, string $modelIdentifier): array + { + $allObjects = []; + + // Extract propertyDefinitionMap once for all objects + $propertyDefinitionMap = $this->extractPropertyDefinitionMap($xmlData); + + // Create model object first + if (isset($xmlData['_attributes']) || isset($xmlData['name'])) { + $modelMetadata = [ + 'identifier' => $modelIdentifier, + 'name' => $xmlData['name'] ?? '', + 'documentation' => $xmlData['documentation'] ?? '', + 'properties' => $xmlData['properties'] ?? [], + 'propertyDefinitionMap' => $propertyDefinitionMap + ]; + + if (isset($xmlData['_attributes'])) { + $modelMetadata = array_merge($modelMetadata, $xmlData['_attributes']); + } + + $allObjects[] = $this->createModelObjectDirect($modelMetadata, $modelIdentifier); + } + + // Process each section type directly (no intermediate normalization) + $sections = [ + 'elements' => 'element', + 'relationships' => 'relationship', + 'organizations' => 'organization', + 'views' => 'view', + 'property_definitions' => 'property_definition' + ]; + + foreach ($sections as $sectionName => $schemaType) { + $sectionData = $this->findSectionData($xmlData, $sectionName); + if (!empty($sectionData)) { + $sectionObjects = $this->transformSectionObjectsBatch( + $sectionData, + $schemaType, + $modelIdentifier, + $propertyDefinitionMap + ); + $allObjects = array_merge($allObjects, $sectionObjects); + } + } + + return $allObjects; + } + + /** + * Create model object directly with cached configuration + * + * @param array $metadata Model metadata + * @param string $modelIdentifier Model identifier + * @return array Model object with @self structure + */ + private function createModelObjectDirect(array $metadata, string $modelIdentifier): array + { + return [ + '@self' => [ + 'register' => $this->cachedConfig['registerId'], + 'schema' => $this->cachedConfig['schemaIds']['model'], + 'id' => $modelIdentifier, + 'published' => date('Y-m-d\TH:i:s\Z') + ], + 'identifier' => $modelIdentifier, + 'section' => 'model', + 'model_identifier' => $modelIdentifier + ] + $metadata; + } + + /** + * Find section data efficiently without complex nested searches + * + * @param array $xmlData Parsed XML data + * @param string $sectionName Section name to find + * @return array Section data or empty array + */ + private function findSectionData(array $xmlData, string $sectionName): array + { + // Direct lookup first + if (isset($xmlData[$sectionName])) { + return $xmlData[$sectionName]; + } + + // Alternative names lookup + $alternatives = [ + 'views' => ['diagrams'], + 'organizations' => ['organisation'], + 'property_definitions' => ['propertyDefinitions', 'propertydefinitions'] + ]; + + if (isset($alternatives[$sectionName])) { + foreach ($alternatives[$sectionName] as $altName) { + if (isset($xmlData[$altName])) { + return $xmlData[$altName]; + } + } + } + + return []; + } + + /** + * Transform section objects in batch with minimal overhead + * + * @param array $sectionData Section data from XML + * @param string $schemaType Schema type (singular) + * @param string $modelIdentifier Model identifier + * @param array $propertyDefinitionMap Property definition map + * @return array Array of transformed objects + */ + private function transformSectionObjectsBatch( + array $sectionData, + string $schemaType, + string $modelIdentifier, + array $propertyDefinitionMap + ): array { + $objects = []; + + // Find items in section (simplified version) + $items = $this->findItemsSimplified($sectionData, $schemaType); + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $identifier = $this->extractIdentifier($item, $schemaType); + if (!$identifier) { + continue; + } + + // Create object directly (minimal processing) + $object = [ + '@self' => [ + 'register' => $this->cachedConfig['registerId'], + 'schema' => $this->cachedConfig['schemaIds'][$schemaType], + 'id' => $identifier, + 'published' => date('Y-m-d\TH:i:s\Z') + ], + 'identifier' => $identifier, + 'section' => $schemaType, + 'model_identifier' => $modelIdentifier, + 'xml' => $this->extractEssentialXmlData($item) // OPTIMIZATION: Store only essential XML data + ]; + + // Extract name from XML if it exists + if (isset($item['name'])) { + if (is_array($item['name']) && isset($item['name']['_value'])) { + $object['name'] = $item['name']['_value']; + } elseif (is_string($item['name'])) { + $object['name'] = $item['name']; + } + } + + // Extract documentation from XML if it exists and set to summary + if (isset($item['documentation'])) { + if (is_array($item['documentation']) && isset($item['documentation']['_value'])) { + $object['summary'] = $item['documentation']['_value']; + } elseif (is_string($item['documentation'])) { + $object['summary'] = $item['documentation']; + } + } + + // Flatten properties efficiently (if present) + if (isset($item['properties']['property']) && !empty($propertyDefinitionMap)) { + $this->flattenPropertiesBatch($object, $item['properties']['property'], $propertyDefinitionMap); + } + + $objects[] = $object; + } + + return $objects; + } + + /** + * Simplified item finding for better performance + * + * @param array $sectionData Section data + * @param string $sectionType Section type + * @return array Items array + */ + private function findItemsSimplified(array $sectionData, string $sectionType): array + { + // Handle views with diagrams structure + if ($sectionType === 'view' && isset($sectionData['diagrams']['view'])) { + $viewData = $sectionData['diagrams']['view']; + return isset($viewData[0]) ? $viewData : [$viewData]; + } + + // Try common patterns + $patterns = [ + $sectionType, // singular: element, relationship, etc. + $sectionType . 's', // plural: elements, relationships, etc. + 'item', // organizations use 'item' + 'propertyDefinition' // property definitions + ]; + + foreach ($patterns as $pattern) { + if (isset($sectionData[$pattern])) { + $data = $sectionData[$pattern]; + return is_array($data) && isset($data[0]) ? $data : [$data]; + } + } + + // Fallback: treat section data as single item + return [$sectionData]; + } + + /** + * Flatten properties in batch for better performance + * + * @param array &$object Object to add properties to (by reference) + * @param array $properties Properties array from XML + * @param array $propertyDefinitionMap Property definition map + * @return void + */ + private function flattenPropertiesBatch(array &$object, array $properties, array $propertyDefinitionMap): void + { + $props = isset($properties[0]) ? $properties : [$properties]; + $processedProperties = []; + + foreach ($props as $prop) { + if (!isset($prop['_attributes']['propertyDefinitionRef'])) { + continue; + } + + $defRef = $prop['_attributes']['propertyDefinitionRef']; + $value = $prop['value']['_value'] ?? $prop['value'] ?? null; + + if ($value !== null && isset($propertyDefinitionMap[$defRef])) { + $propertyName = $propertyDefinitionMap[$defRef]; + $camelCaseName = $this->convertToCamelCase($propertyName); + $object[$camelCaseName] = $value; + + // Store property mapping for reference + if (!isset($object['_propertyMapping'])) { + $object['_propertyMapping'] = []; + } + $object['_propertyMapping'][$camelCaseName] = $propertyName; + + $processedProperties[] = [ + 'original' => $propertyName, + 'camelCase' => $camelCaseName, + 'value' => $value + ]; + + // Set slug for Object ID property + if (strtolower($propertyName) === 'object id') { + $object['@self']['slug'] = $value; + } + } + } + + // OPTIMIZATION: Removed debug logging from tight loop for performance + } + + /** + * Convert property names with spaces to camelCase for better database compatibility + * + * Examples: + * - "Object ID" -> "objectId" + * - "Business Unit" -> "businessUnit" + * - "System Name" -> "systemName" + * + * @param string $propertyName Property name that may contain spaces + * @return string CamelCase version of the property name + */ + private function convertToCamelCase(string $propertyName): string + { + // OPTIMIZATION: Check cache first to avoid redundant conversions + if (isset($this->camelCaseCache[$propertyName])) { + return $this->camelCaseCache[$propertyName]; + } + + // Remove any leading/trailing whitespace + $propertyName = trim($propertyName); + + // Split by spaces and convert to camelCase + $words = explode(' ', $propertyName); + + if (count($words) === 1) { + // Single word, just lowercase it + $result = strtolower($words[0]); + } else { + // First word is lowercase, subsequent words are capitalized + $camelCase = strtolower($words[0]); + + for ($i = 1; $i < count($words); $i++) { + $camelCase .= ucfirst(strtolower($words[$i])); + } + + $result = $camelCase; + } + + // OPTIMIZATION: Cache the result for future use + $this->camelCaseCache[$propertyName] = $result; + + return $result; + } + + /** + * Get property mapping information for debugging and reference + * + * This method returns a mapping of original property names to their camelCase equivalents + * which can be useful for understanding how properties are being processed. + * + * @param array $propertyDefinitionMap The original property definition map + * @return array Mapping of original names to camelCase names + */ + public function getPropertyNameMapping(array $propertyDefinitionMap): array + { + $mapping = []; + + foreach ($propertyDefinitionMap as $propertyRef => $originalName) { + $mapping[$originalName] = $this->convertToCamelCase($originalName); + } + + return $mapping; + } + + /** + * Calculate optimized statistics for performance reporting + * + * @param array $savedObjects Saved objects from ObjectService::saveObjects + * @return array Statistics array + */ + private function calculateOptimizedStatistics(array $savedObjects): array + { + $statistics = [ + 'summary' => [ + 'total_objects_created' => 0, + 'total_objects_updated' => 0, + 'total_objects_deleted' => 0, + 'total_objects_skipped' => 0, + 'total_errors' => 0 + ] + ]; + + if ($this->lastSaveResult !== null) { + $saveResult = $this->lastSaveResult; + $statistics['summary'] = [ + 'total_objects_created' => count($saveResult['saved'] ?? []), + 'total_objects_updated' => count($saveResult['updated'] ?? []), + 'total_objects_deleted' => 0, + 'total_objects_skipped' => count($saveResult['skipped'] ?? []), + 'total_errors' => count($saveResult['invalid'] ?? []) + ]; + } + + return $statistics; + } + + /** + * Extract GEMMA type from an object using multiple possible property names + * + * This method tries different variations of GEMMA type property names to ensure + * compatibility with different ArchiMate model variations. + * + * @param array $object The object to extract GEMMA type from + * @return string|null The GEMMA type value or null if not found + */ + private function extractGemmaType(array $object): ?string + { + // Try various possible property names for GEMMA type + $possiblePropertyNames = [ + 'gemmaType', // Standard camelCase conversion of "GEMMA Type" + 'gemmatype', // Lowercase version + 'GemmaType', // PascalCase version + 'GEMMA_Type', // Underscore version + 'gemma_type', // Lowercase underscore version + 'GEMMAType', // All caps first word + 'type', // Sometimes just "Type" in models + 'elementType', // Alternative naming + 'componentType' // Another alternative + ]; + + foreach ($possiblePropertyNames as $propertyName) { + if (isset($object[$propertyName]) && !empty($object[$propertyName])) { + $value = (string) $object[$propertyName]; + + // Log the first successful match for debugging + if (!$this->gemmaTypePropertyFound) { + $this->logger->debug('GEMMA Type property found', [ + 'property_name' => $propertyName, + 'value' => $value, + 'object_id' => $object['identifier'] ?? 'unknown' + ]); + $this->gemmaTypePropertyFound = true; + } + + return $value; + } + } + + // If no direct property found, check _propertyMapping for original property names + if (isset($object['_propertyMapping'])) { + foreach ($object['_propertyMapping'] as $camelCase => $original) { + // Check if the original property name contains "gemma" or "type" + if (stripos($original, 'gemma') !== false && stripos($original, 'type') !== false) { + if (isset($object[$camelCase]) && !empty($object[$camelCase])) { + $this->logger->debug('GEMMA Type found via property mapping', [ + 'camel_case_name' => $camelCase, + 'original_name' => $original, + 'value' => $object[$camelCase], + 'object_id' => $object['identifier'] ?? 'unknown' + ]); + return (string) $object[$camelCase]; + } + } + } + } + + return null; + } + + /** + * Process GEMMA Referentiecomponent-Standaard relationships with Verbindingsrol support + * + * This method analyzes all objects to find Referentiecomponenten and Standaarden, + * then uses relationships to link them together based on Verbindingsrol property. + * Each Referentiecomponent gets two properties: + * - 'aanbevolenStandaarden' array for standards with Verbindingsrol = "Aanbevolen" + * - 'verplichteStandaarden' array for standards with Verbindingsrol = "Verplicht" + * + * @param array $objects All objects from the import + * @return array Objects with enhanced Referentiecomponent data + */ + private function processGemmaReferenceComponentStandards(array $objects): array + { + $this->logger->info('Processing GEMMA Referentiecomponent-Standaard relationships with optimized single-pass algorithm'); + + // OPTIMIZATION: Single-pass processing - collect all data types at once + $referentieComponenten = []; + $standaarden = []; + $gemmaRelationshipMap = []; + + // Debug: Count objects and property variations + $elementCount = 0; + $elementsWithGemmaType = 0; + $gemmaTypeVariations = []; + + // PASS 1: Collect Referentiecomponenten and Standaarden, process relationships immediately + foreach ($objects as $index => $object) { + // Debug: Count elements and GEMMA types + if (isset($object['section']) && $object['section'] === 'element') { + $elementCount++; + + // Check for various possible GEMMA type property names + $gemmaTypeValue = $this->extractGemmaType($object); + if ($gemmaTypeValue !== null) { + $elementsWithGemmaType++; + + // Track GEMMA type variations for debugging + if (!isset($gemmaTypeVariations[$gemmaTypeValue])) { + $gemmaTypeVariations[$gemmaTypeValue] = 0; + } + $gemmaTypeVariations[$gemmaTypeValue]++; + + if ($gemmaTypeValue === 'Referentiecomponent') { + $referentieComponenten[$object['identifier']] = $index; + } elseif ($gemmaTypeValue === 'Standaard') { + $standaarden[$object['identifier']] = $index; + } + } + } + + // Process relationships immediately when found (no separate collection needed) + if (isset($object['section']) && $object['section'] === 'relationship') { + $this->processRelationshipImmediate($object, $referentieComponenten, $standaarden, $gemmaRelationshipMap); + } + } + + // Enhanced debug logging + $this->logger->info('GEMMA objects processing complete', [ + 'total_elements' => $elementCount, + 'elements_with_gemma_type' => $elementsWithGemmaType, + 'gemma_type_variations' => $gemmaTypeVariations, + 'referentiecomponenten_count' => count($referentieComponenten), + 'standaarden_count' => count($standaarden), + 'processed_relationships' => count($gemmaRelationshipMap) + ]); + + // Additional debugging if no GEMMA types found + if ($elementsWithGemmaType === 0 && $elementCount > 0) { + $this->logger->warning('No GEMMA types found in any elements', [ + 'total_elements_processed' => $elementCount, + 'sample_element_keys' => 'Will need to examine individual objects' + ]); + } + + // STEP 2: Apply the processed relationship mappings to Referentiecomponenten + $enhancedCount = 0; + foreach ($gemmaRelationshipMap as $referentieComponentId => $standaardenMap) { + if (isset($referentieComponenten[$referentieComponentId])) { + $objectIndex = $referentieComponenten[$referentieComponentId]; + + // Remove duplicates and add the properties + $aanbevolenStandaarden = array_unique($standaardenMap['aanbevolen']); + $verplichteStandaarden = array_unique($standaardenMap['verplicht']); + + $objects[$objectIndex]['aanbevolenStandaarden'] = $aanbevolenStandaarden; + $objects[$objectIndex]['verplichteStandaarden'] = $verplichteStandaarden; + + // Also add combined array for backward compatibility + $allStandaarden = array_unique(array_merge($aanbevolenStandaarden, $verplichteStandaarden)); + $objects[$objectIndex]['standaarden'] = $allStandaarden; + + $this->logger->info('Enhanced Referentiecomponent with categorized standaarden', [ + 'referentiecomponent_id' => $referentieComponentId, + 'referentiecomponent_name' => $objects[$objectIndex]['name'] ?? 'Unknown', + 'aanbevolen_count' => count($aanbevolenStandaarden), + 'verplicht_count' => count($verplichteStandaarden), + 'aanbevolen_ids' => $aanbevolenStandaarden, + 'verplicht_ids' => $verplichteStandaarden + ]); + + $enhancedCount++; + } + } + + $this->logger->info('GEMMA Referentiecomponent-Standaard processing completed', [ + 'referentiecomponenten_enhanced' => $enhancedCount, + 'total_referentiecomponenten' => count($referentieComponenten), + 'total_relationships_processed' => count($gemmaRelationshipMap) + ]); + + return $objects; + } + + /** + * OPTIMIZATION: Process relationship immediately when found (single-pass algorithm) + * + * @param array $relationship The relationship object + * @param array $referentieComponenten Array of Referentiecomponent identifiers + * @param array $standaarden Array of Standaard identifiers + * @param array &$gemmaRelationshipMap The relationship map to update (by reference) + * @return void + */ + private function processRelationshipImmediate(array $relationship, array $referentieComponenten, array $standaarden, array &$gemmaRelationshipMap): void + { + // Get source and target from relationship XML or flattened properties + $source = $this->extractRelationshipEndpoint($relationship, 'source'); + $target = $this->extractRelationshipEndpoint($relationship, 'target'); + + if (!$source || !$target) { + return; + } + + // Get Verbindingsrol from flattened properties (camelCase: verbindingsrol) + $verbindingsrol = $relationship['verbindingsrol'] ?? null; + + // Skip if no Verbindingsrol is defined + if (!$verbindingsrol) { + return; + } + + // Check if one end is a Referentiecomponent and the other is a Standaard + $refCompId = null; + $standaardId = null; + + if (isset($referentieComponenten[$source]) && isset($standaarden[$target])) { + // Referentiecomponent -> Standaard + $refCompId = $source; + $standaardId = $target; + } elseif (isset($standaarden[$source]) && isset($referentieComponenten[$target])) { + // Standaard -> Referentiecomponent (reverse direction) + $refCompId = $target; + $standaardId = $source; + } + + if ($refCompId && $standaardId) { + // Initialize arrays if not exists + if (!isset($gemmaRelationshipMap[$refCompId])) { + $gemmaRelationshipMap[$refCompId] = [ + 'aanbevolen' => [], + 'verplicht' => [] + ]; + } + + // Add to appropriate array based on Verbindingsrol + if (strtolower($verbindingsrol) === 'aanbevolen') { + $gemmaRelationshipMap[$refCompId]['aanbevolen'][] = $standaardId; + } elseif (strtolower($verbindingsrol) === 'verplicht') { + $gemmaRelationshipMap[$refCompId]['verplicht'][] = $standaardId; + } + } + } + + /** + * Extract relationship endpoint (source or target) from relationship object + * + * @param array $relationship The relationship object + * @param string $endpoint Either 'source' or 'target' + * @return string|null The endpoint identifier or null if not found + */ + private function extractRelationshipEndpoint(array $relationship, string $endpoint): ?string + { + // Try flattened camelCase property first + if (isset($relationship[$endpoint])) { + return $relationship[$endpoint]; + } + + // Try XML structure + if (isset($relationship['xml'][$endpoint])) { + $endpointData = $relationship['xml'][$endpoint]; + + // Handle different XML structures + if (is_string($endpointData)) { + return $endpointData; + } elseif (is_array($endpointData)) { + // Try _attributes.href or _value + if (isset($endpointData['_attributes']['href'])) { + return $endpointData['_attributes']['href']; + } elseif (isset($endpointData['_value'])) { + return $endpointData['_value']; + } + } + } + + // Try direct XML access for ArchiMate format + if (isset($relationship['xml']['_attributes'])) { + $attr = $relationship['xml']['_attributes']; + if ($endpoint === 'source' && isset($attr['source'])) { + return $attr['source']; + } elseif ($endpoint === 'target' && isset($attr['target'])) { + return $attr['target']; + } + } + + return null; + } + + /** + * OPTIMIZATION: Extract only essential XML data to reduce memory usage by 20-30% + * + * Instead of storing the complete XML structure, this method extracts only + * the essential data needed for round-trip fidelity and export functionality. + * + * @param array $item The complete XML item data + * @return array Essential XML data for storage + */ + private function extractEssentialXmlData(array $item): array + { + $essential = []; + + // Always preserve core attributes (needed for export) + if (isset($item['_attributes'])) { + $essential['_attributes'] = $item['_attributes']; + } + + // Preserve name and documentation (already extracted to root level but needed for export) + if (isset($item['name'])) { + $essential['name'] = $item['name']; + } + + if (isset($item['documentation'])) { + $essential['documentation'] = $item['documentation']; + } + + // Preserve properties structure (needed for property mapping) + if (isset($item['properties'])) { + $essential['properties'] = $item['properties']; + } + + // For relationships, preserve source/target information + if (isset($item['source'])) { + $essential['source'] = $item['source']; + } + + if (isset($item['target'])) { + $essential['target'] = $item['target']; + } + + // Preserve any other critical ArchiMate-specific fields + $criticalFields = ['type', 'viewpoint', 'accessType', 'isDirected']; + foreach ($criticalFields as $field) { + if (isset($item[$field])) { + $essential[$field] = $item[$field]; + } + } + + // Add a marker to indicate this is essential data (for debugging) + $essential['_essential_data'] = true; + + return $essential; + } } \ No newline at end of file diff --git a/lib/Service/ArchiMateService_backup.php b/lib/Service/ArchiMateService_backup.php index 2d00a764..528a8e40 100644 --- a/lib/Service/ArchiMateService_backup.php +++ b/lib/Service/ArchiMateService_backup.php @@ -1824,14 +1824,23 @@ private function processSchemaTypeSynchronous(array $items, string $schemaType, 'schema_id' => $schemaId ]); - $savedObjects = $objectService->saveObjects( + $saveResult = $objectService->saveObjects( objects: $allProcessedObjects, register: $registerId, schema: $schemaId ); - // Analyze the saved objects to determine created vs updated counts - $actionCounts = $this->analyzeBatchObjectActions($savedObjects); + // Extract saved objects from the new structured return format + $savedObjects = array_merge( + $saveResult['saved'] ?? [], + $saveResult['updated'] ?? [] + ); + + // Use actual counts from the structured return instead of analyzing objects + $actionCounts = [ + 'created' => count($saveResult['saved'] ?? []), + 'updated' => count($saveResult['updated'] ?? []) + ]; $totalSaved = count($savedObjects); // Store the action counts for return value diff --git a/lib/Service/ContactpersoonService.php b/lib/Service/ContactpersoonService.php index 137fe040..422441c6 100644 --- a/lib/Service/ContactpersoonService.php +++ b/lib/Service/ContactpersoonService.php @@ -108,21 +108,61 @@ public function processContactpersoon(object $contactpersoonObject, bool $isUpda $user = $userManager->get($username); if (!$user) { - // Create user account - $this->logger->info('ContactpersoonService: Creating user account for contactpersoon', [ - 'contactId' => $contactId, - 'username' => $username - ]); - - $success = $this->contactPersonHandler->createUserAccount($contactpersoonObject); - if (!$success) { - throw new \Exception('Failed to create user account'); + // Check if organization is active before creating user account + $organizationUuid = $contactData['organisation'] ?? $contactData['organisatie'] ?? ''; + + if (!empty($organizationUuid)) { + try { + $organisationMapper = \OC::$server->get('OCA\OpenRegister\Db\OrganisationMapper'); + $organisationEntity = $organisationMapper->findByUuid($organizationUuid); + + if ($organisationEntity && $organisationEntity->getActive()) { + // Create user account - organization is active + $this->logger->info('ContactpersoonService: Creating user account for contactpersoon (org is active)', [ + 'contactId' => $contactId, + 'username' => $username, + 'organizationUuid' => $organizationUuid, + 'organizationActive' => true + ]); + + $success = $this->contactPersonHandler->createUserAccount($contactpersoonObject); + if (!$success) { + throw new \Exception('Failed to create user account'); + } + + // Link user to organization entity + $this->contactPersonHandler->addUserToOrganizationEntity($contactpersoonObject, $username); + + // Update contactpersoon object owner to user UID + $this->updateContactpersoonObjectOwner($contactpersoonObject, $username); + + $this->logger->info('ContactpersoonService: Successfully created user account', [ + 'contactId' => $contactId, + 'username' => $username + ]); + } else { + $this->logger->info('ContactpersoonService: Skipping user creation - organization not active or not found', [ + 'contactId' => $contactId, + 'organizationUuid' => $organizationUuid, + 'organizationFound' => $organisationEntity !== null, + 'organizationActive' => $organisationEntity ? $organisationEntity->getActive() : false + ]); + return false; + } + } catch (\Exception $e) { + $this->logger->info('ContactpersoonService: Skipping user creation - organization not found in entity table (not active)', [ + 'contactId' => $contactId, + 'organizationUuid' => $organizationUuid, + 'reason' => 'Organization not found in entity table' + ]); + return false; + } + } else { + $this->logger->warning('ContactpersoonService: Contactpersoon has no organization reference, skipping user creation', [ + 'contactId' => $contactId + ]); + return false; } - - $this->logger->info('ContactpersoonService: Successfully created user account', [ - 'contactId' => $contactId, - 'username' => $username - ]); } else { $this->logger->info('ContactpersoonService: User account already exists', [ 'contactId' => $contactId, @@ -429,4 +469,77 @@ public function getContactPersonsForOrganization(string $organizationUuid): arra return []; } } + + /** + * Updates the contactpersoon object's @self metadata to set owner to the user UID + * + * @param object $contactObject The contactpersoon object to update + * @param string $userUID The user UID to set as owner + * @return void + */ + private function updateContactpersoonObjectOwner(object $contactObject, string $userUID): void + { + try { + $contactId = $contactObject->getUuid(); + + // Get configuration for register and schema + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; + $contactSchema = $voorzieningenConfig['contactpersoon_schema'] ?? ''; + + if (empty($register) || empty($contactSchema)) { + $this->logger->warning('ContactpersoonService: Cannot update object owner - missing configuration', [ + 'contactId' => $contactId, + 'register' => $register, + 'contactSchema' => $contactSchema + ]); + return; + } + + $this->logger->info('ContactpersoonService: Updating contactpersoon object owner', [ + 'contactId' => $contactId, + 'userUID' => $userUID, + 'register' => $register, + 'schema' => $contactSchema + ]); + + // 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; + + // Update the object with the new @self metadata + $currentObject['@self'] = $selfMetadata; + $contactObject->setObject($currentObject); + + // Save the updated object using ObjectService + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + $objectService->saveObject( + object: $contactObject, + register: $register, + schema: $contactSchema, + rbac: false, + multi: false + ); + + $this->logger->info('ContactpersoonService: Successfully updated contactpersoon object owner', [ + 'contactId' => $contactId, + 'userUID' => $userUID, + 'ownerSet' => $selfMetadata['owner'] + ]); + + } catch (\Exception $e) { + $this->logger->error('ContactpersoonService: Failed to update contactpersoon object owner', [ + 'contactId' => $contactObject->getUuid(), + 'userUID' => $userUID, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } + } } \ No newline at end of file diff --git a/lib/Service/GebruikSyncService.php b/lib/Service/GebruikSyncService.php new file mode 100644 index 00000000..4d44a81a --- /dev/null +++ b/lib/Service/GebruikSyncService.php @@ -0,0 +1,423 @@ + + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl + * @version 1.0.0 + * @link https://github.com/conduction/nextcloud-software-catalog + */ +class GebruikSyncService +{ + private LoggerInterface $logger; + private SettingsService $settingsService; + + /** + * Constructor for GebruikSyncService + * + * @param LoggerInterface $logger Logger for debugging and error reporting + * @param SettingsService $settingsService Service for retrieving configuration settings + */ + public function __construct( + LoggerInterface $logger, + SettingsService $settingsService + ) { + $this->logger = $logger; + $this->settingsService = $settingsService; + } + + /** + * Process a specific gebruik object + * + * This method handles both AMEF elements processing and status auto-update + * + * @param ObjectEntity $gebruikObject The gebruik object to process + * @return array Processing statistics + */ + public function processSpecificGebruik(ObjectEntity $gebruikObject): array + { + $startTime = microtime(true); + $stats = [ + 'startTime' => date('Y-m-d H:i:s'), + 'gebruikId' => $gebruikObject->getUuid(), + 'amefElementsProcessed' => 0, + 'statusUpdated' => false, + 'errors' => [], + 'duration' => 0 + ]; + + try { + $gebruikData = $gebruikObject->getObject(); + $gebruikUuid = $gebruikObject->getUuid(); + + $this->logger->critical('šŸ”„ PROCESSING GEBRUIK OBJECT', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'afnemer' => $gebruikData['afnemer']['naam'] ?? 'Unknown', + 'product' => $gebruikData['product']['naam'] ?? 'Unknown', + 'currentStatus' => $gebruikData['status'] ?? 'Unknown', + 'timestamp' => date('Y-m-d H:i:s') + ]); + + // Step 1: Process gebruiktVoorReferentiecomponenten for AMEF elements + $amefStats = $this->processAmefElements($gebruikObject); + $stats['amefElementsProcessed'] = $amefStats['amefElementsProcessed']; + $stats['errors'] = array_merge($stats['errors'], $amefStats['errors']); + + // Step 2: Auto-update status based on dates + $statusStats = $this->updateStatusBasedOnDates($gebruikObject); + $stats['statusUpdated'] = $statusStats['statusUpdated']; + $stats['errors'] = array_merge($stats['errors'], $statusStats['errors']); + + $stats['endTime'] = date('Y-m-d H:i:s'); + $stats['duration'] = round(microtime(true) - $startTime, 3); + + $this->logger->critical('āœ… GEBRUIK PROCESSING COMPLETED', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'stats' => $stats, + 'processingTime' => $stats['duration'] . 's' + ]); + + return $stats; + + } catch (Exception $e) { + $stats['errors'][] = $e->getMessage(); + $stats['duration'] = round(microtime(true) - $startTime, 3); + + $this->logger->error('šŸ’„ GEBRUIK PROCESSING ERROR', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikObject->getUuid(), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); + + return $stats; + } + } + + /** + * Process AMEF elements from gebruiktVoorReferentiecomponenten + * + * Searches for AMEF elements based on IDs in gebruiktVoorReferentiecomponenten + * and adds their slugs to the amefElements array + * + * @param ObjectEntity $gebruikObject The gebruik object to process + * @return array Processing statistics + */ + private function processAmefElements(ObjectEntity $gebruikObject): array + { + $stats = [ + 'amefElementsProcessed' => 0, + 'errors' => [] + ]; + + try { + $gebruikData = $gebruikObject->getObject(); + $gebruikUuid = $gebruikObject->getUuid(); + + // Get the referentiecomponenten IDs + $referentieComponenten = $gebruikData['gebruiktVoorReferentiecomponenten'] ?? []; + + if (empty($referentieComponenten)) { + $this->logger->info('šŸ” No referentiecomponenten found for gebruik object', [ + 'gebruikId' => $gebruikUuid + ]); + return $stats; + } + + $this->logger->critical('šŸ” PROCESSING REFERENTIECOMPONENTEN', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'referentieComponentenCount' => count($referentieComponenten) + ]); + + // Extract IDs from referentiecomponenten + $referentieIds = []; + foreach ($referentieComponenten as $component) { + if (isset($component['id'])) { + $referentieIds[] = $component['id']; + } + } + + if (empty($referentieIds)) { + $this->logger->warning('āš ļø No valid IDs found in referentiecomponenten', [ + 'gebruikId' => $gebruikUuid + ]); + return $stats; + } + + // Get AMEF register configuration + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $amefRegister = $voorzieningenConfig['amef_register'] ?? ''; + $elementSchema = $voorzieningenConfig['element_schema'] ?? ''; + + if (empty($amefRegister) || empty($elementSchema)) { + $stats['errors'][] = 'AMEF register or element schema not configured'; + $this->logger->error('āŒ AMEF configuration missing', [ + 'app' => 'softwarecatalog', + 'amefRegister' => $amefRegister, + 'elementSchema' => $elementSchema + ]); + return $stats; + } + + // Search for AMEF elements + $amefElements = $this->searchAmefElementsByIds($referentieIds, $amefRegister, $elementSchema); + + // Extract slugs from found AMEF elements + $amefSlugs = []; + foreach ($amefElements as $amefElement) { + $amefData = $amefElement->getObject(); + if (isset($amefData['slug'])) { + $amefSlugs[] = $amefData['slug']; + $stats['amefElementsProcessed']++; + } + } + + // Update the gebruik object with AMEF slugs + if (!empty($amefSlugs)) { + $gebruikData['amefElements'] = array_unique($amefSlugs); + $this->updateGebruikObject($gebruikObject, $gebruikData); + + $this->logger->critical('āœ… AMEF ELEMENTS UPDATED', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'amefSlugs' => $amefSlugs, + 'amefElementsCount' => count($amefSlugs) + ]); + } else { + $this->logger->info('ā„¹ļø No AMEF elements with slugs found', [ + 'gebruikId' => $gebruikUuid + ]); + } + + return $stats; + + } catch (Exception $e) { + $stats['errors'][] = 'AMEF processing error: ' . $e->getMessage(); + $this->logger->error('šŸ’„ AMEF PROCESSING ERROR', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikObject->getUuid(), + 'exception' => $e->getMessage() + ]); + + return $stats; + } + } + + /** + * Search for AMEF elements by IDs + * + * Uses searchObjects to find AMEF elements based on provided IDs. + * Since searchObjects may not support direct IDs array parameter, + * this method implements multiple individual searches. + * + * @param array $ids Array of IDs to search for + * @param string $register AMEF register ID + * @param string $schema Element schema ID + * @return array Array of found ObjectEntity objects + */ + private function searchAmefElementsByIds(array $ids, string $register, string $schema): array + { + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + $foundElements = []; + + foreach ($ids as $id) { + try { + // Try to search by ID + $query = [ + '@self' => [ + 'register' => (int) $register, + 'schema' => (int) $schema + ], + 'id' => $id + ]; + + $elements = $objectService->searchObjects($query); + $foundElements = array_merge($foundElements, $elements); + + } catch (Exception $e) { + $this->logger->warning('āš ļø Failed to search for AMEF element', [ + 'app' => 'softwarecatalog', + 'id' => $id, + 'error' => $e->getMessage() + ]); + } + } + + $this->logger->info('šŸ” AMEF elements search completed', [ + 'app' => 'softwarecatalog', + 'searchedIds' => $ids, + 'foundElementsCount' => count($foundElements) + ]); + + return $foundElements; + } + + /** + * Update status based on date fields + * + * Looks at all status date fields and updates the status to the one + * with the highest date that is not in the future + * + * @param ObjectEntity $gebruikObject The gebruik object to process + * @return array Processing statistics + */ + private function updateStatusBasedOnDates(ObjectEntity $gebruikObject): array + { + $stats = [ + 'statusUpdated' => false, + 'errors' => [] + ]; + + try { + $gebruikData = $gebruikObject->getObject(); + $gebruikUuid = $gebruikObject->getUuid(); + $currentStatus = $gebruikData['status'] ?? ''; + + // Define status dates mapping + $statusDates = [ + 'Verwerving' => $gebruikData['startDatumVerwerving'] ?? null, + 'Gepland' => $gebruikData['startDatumGepland'] ?? null, + 'In productie' => $gebruikData['startDatumInProductie'] ?? null, + 'Uit te faseren' => $gebruikData['startDatumUitTeFaseren'] ?? null, + 'Uitgefaseerd' => $gebruikData['startDatumUitGefaseerd'] ?? null + ]; + + $this->logger->info('šŸ” CHECKING STATUS DATES', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'currentStatus' => $currentStatus, + 'statusDates' => $statusDates + ]); + + // Find the highest date that is not in the future + $now = new DateTime(); + $targetStatus = null; + $targetDate = null; + + foreach ($statusDates as $status => $dateString) { + if (!empty($dateString)) { + try { + $date = new DateTime($dateString); + + // Only consider dates that are not in the future + if ($date <= $now) { + if ($targetDate === null || $date > $targetDate) { + $targetDate = $date; + $targetStatus = $status; + } + } + } catch (Exception $e) { + $this->logger->warning('āš ļø Invalid date format', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'status' => $status, + 'dateString' => $dateString, + 'error' => $e->getMessage() + ]); + } + } + } + + // Update status if we found a different one + if ($targetStatus && $targetStatus !== $currentStatus) { + $gebruikData['status'] = $targetStatus; + $this->updateGebruikObject($gebruikObject, $gebruikData); + $stats['statusUpdated'] = true; + + $this->logger->critical('āœ… STATUS AUTO-UPDATED', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'oldStatus' => $currentStatus, + 'newStatus' => $targetStatus, + 'basedOnDate' => $targetDate ? $targetDate->format('Y-m-d') : null + ]); + } else { + $this->logger->info('ā„¹ļø No status update needed', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikUuid, + 'currentStatus' => $currentStatus, + 'targetStatus' => $targetStatus + ]); + } + + return $stats; + + } catch (Exception $e) { + $stats['errors'][] = 'Status update error: ' . $e->getMessage(); + $this->logger->error('šŸ’„ STATUS UPDATE ERROR', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikObject->getUuid(), + 'exception' => $e->getMessage() + ]); + + return $stats; + } + } + + /** + * Update a gebruik object in OpenRegister + * + * @param ObjectEntity $gebruikObject The object to update + * @param array $updatedData The updated data + * @return void + * @throws Exception If the update fails + */ + private function updateGebruikObject(ObjectEntity $gebruikObject, array $updatedData): void + { + try { + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + + // Get voorzieningenConfig to find the correct register and schema + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; + $gebruikSchema = $voorzieningenConfig['gebruik_schema'] ?? ''; + + if (empty($register) || empty($gebruikSchema)) { + throw new Exception('Register or gebruik schema not configured'); + } + + // Update the object + $objectService->saveObject( + object: $updatedData, + register: (int) $register, + schema: (int) $gebruikSchema, + id: $gebruikObject->getUuid() + ); + + $this->logger->info('āœ… Gebruik object updated successfully', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikObject->getUuid() + ]); + + } catch (Exception $e) { + $this->logger->error('āŒ Failed to update gebruik object', [ + 'app' => 'softwarecatalog', + 'gebruikId' => $gebruikObject->getUuid(), + 'error' => $e->getMessage() + ]); + throw $e; + } + } +} diff --git a/lib/Service/OrganisatieService.php b/lib/Service/OrganisatieService.php index a5bbfffa..eeed4dd7 100644 --- a/lib/Service/OrganisatieService.php +++ b/lib/Service/OrganisatieService.php @@ -19,6 +19,7 @@ namespace OCA\SoftwareCatalog\Service; use OCA\SoftwareCatalog\Service\SoftwareCatalogue\OrganizationHandler; +use OCP\IUserManager; use Psr\Log\LoggerInterface; use Psr\Container\ContainerInterface; use OCP\App\IAppManager; @@ -26,10 +27,10 @@ /** * Service for handling organization-specific operations - * + * * This service provides functionality for organization entity creation, * status management, and integration with OpenRegister. - * + * * @category Service * @package OCA\SoftwareCatalog\Service * @author Conduction b.v. @@ -53,7 +54,9 @@ public function __construct( private readonly LoggerInterface $logger, private readonly ContainerInterface $container, private readonly IAppManager $appManager, - private readonly IAppConfig $config + private readonly IAppConfig $config, + private readonly IUserManager $userManager, + private readonly SymfonyEmailService $emailService, ) { } @@ -61,7 +64,7 @@ public function __construct( * Creates an organization entity in OpenRegister * * @param array $objectData The organization object data - * + * * @return object|null The created organisation entity or null on failure */ public function createOrganisationInOpenRegister(array $objectData): ?object @@ -72,34 +75,34 @@ public function createOrganisationInOpenRegister(array $objectData): ?object $this->logger->error('OrganisatieService: No organization UUID provided for creation'); return null; } - + $this->logger->info('OrganisatieService: Creating organization entity in OpenRegister', [ 'organizationUuid' => $organizationUuid, 'naam' => $objectData['naam'] ?? 'Unknown' ]); - + // Map the data for OpenRegister $mappedData = $this->mapOrganizationDataForOpenRegister($objectData); - + // Get organisation service $organisationService = $this->getOrganisationService(); if (!$organisationService) { $this->logger->error('OrganisatieService: OrganisationService not available'); return null; } - + // Create the organization entity $organisationEntity = $this->createOrganisationEntityInternal($organisationService, $mappedData, $organizationUuid); - + if ($organisationEntity) { $this->logger->info('OrganisatieService: Successfully created organization entity', [ 'organizationUuid' => $organizationUuid, 'entityId' => $organisationEntity->getId() ]); } - + return $organisationEntity; - + } catch (\Exception $e) { $this->logger->error('OrganisatieService: Error creating organization entity', [ 'error' => $e->getMessage(), @@ -115,7 +118,7 @@ public function createOrganisationInOpenRegister(array $objectData): ?object * * @param string $organizationUuid The organization UUID * @param array $objectData The organization object data - * + * * @return bool True if update was successful */ public function updateOrganizationStatus(string $organizationUuid, array $objectData): bool @@ -129,21 +132,21 @@ public function updateOrganizationStatus(string $organizationUuid, array $object // Get the organization entity $organisationMapper = $this->container->get('OCA\OpenRegister\Db\OrganisationMapper'); $organisationEntity = $organisationMapper->findByUuid($organizationUuid); - + // Map status from SoftwareCatalog to OpenRegister $active = $this->mapStatus($objectData['beoordeling'] ?? 'actief'); - + // Update the entity $organisationEntity->setActive($active); $organisationMapper->save($organisationEntity); - + $this->logger->info('OrganisatieService: Successfully updated organization status', [ 'organizationUuid' => $organizationUuid, 'active' => $active ]); - + return true; - + } catch (\Exception $e) { $this->logger->error('OrganisatieService: Failed to update organization status', [ 'organizationUuid' => $organizationUuid, @@ -176,7 +179,7 @@ private function getOrganisationService(): ?\OCA\OpenRegister\Service\Organisati * Maps organization data for OpenRegister format * * @param array $objectData The organization object data - * + * * @return array The mapped data for OpenRegister */ private function mapOrganizationDataForOpenRegister(array $objectData): array @@ -195,13 +198,13 @@ private function mapOrganizationDataForOpenRegister(array $objectData): array * Maps status from Software Catalog to OpenRegister format * * @param string $status The status from Software Catalog - * + * * @return bool The mapped active status for OpenRegister */ private function mapStatus(string $status): bool { $normalizedStatus = strtolower(trim($status)); - + return match ($normalizedStatus) { 'actief', 'active' => true, 'inactief', 'inactive', 'deactief' => false, @@ -215,7 +218,7 @@ private function mapStatus(string $status): bool * @param \OCA\OpenRegister\Service\OrganisationService $organisationService The organisation service * @param array $mappedData The mapped data * @param string $organizationUuid The organization UUID - * + * * @return \OCA\OpenRegister\Db\Organisation The created organisation entity */ private function createOrganisationEntityInternal( @@ -223,7 +226,7 @@ private function createOrganisationEntityInternal( array $mappedData, string $organizationUuid ): \OCA\OpenRegister\Db\Organisation { - + $this->logger->info('OrganisatieService: Creating organisation entity', [ 'uuid' => $organizationUuid, 'name' => $mappedData['naam'], @@ -235,21 +238,21 @@ private function createOrganisationEntityInternal( // Let me check what parameters are actually expected and use a simpler approach $organisationEntity = $organisationService->createOrganisation( $mappedData['naam'], // name (string) - $mappedData['type'] ?? '', // description (string) + $mappedData['type'] ?? '', // description (string) false, // addCurrentUser (bool) - don't auto-add current user $organizationUuid // uuid (string) - might be 4th parameter ); - + // Set additional properties after creation if ($organisationEntity) { $organisationEntity->setActive($mappedData['active']); $organisationEntity->setUsers([]); // Will be populated by contact person processing - + // Save the updated entity $organisationMapper = $this->container->get('OCA\OpenRegister\Db\OrganisationMapper'); $organisationMapper->save($organisationEntity); } - + $this->logger->info('OrganisatieService: Organisation entity created successfully', [ 'uuid' => $organizationUuid, 'entityId' => $organisationEntity->getId(), @@ -264,7 +267,7 @@ private function createOrganisationEntityInternal( * * @param string $organizationUuid The organization UUID * @param array $usernames Array of usernames to add - * + * * @return bool True if successful */ public function addUsersToOrganization(string $organizationUuid, array $usernames): bool @@ -278,28 +281,45 @@ public function addUsersToOrganization(string $organizationUuid, array $username // Get the organization entity $organisationMapper = $this->container->get('OCA\OpenRegister\Db\OrganisationMapper'); $organisationEntity = $organisationMapper->findByUuid($organizationUuid); - + // Get current users and merge with new ones $currentUsers = $organisationEntity->getUsers() ?? []; $allUsers = array_unique(array_merge($currentUsers, $usernames)); - + + foreach($usernames as $username) { + $user = $this->userManager->get($username); + + $userData = [ + 'username' => $user->getUID(), + 'email' => $user->getEMailAddress(), + 'name' => $user->getDisplayName(), + ]; + + $this->emailService->sendUserUpdateEmail($userData, $organisationEntity->jsonSerialize()); + } + // Update the entity $organisationEntity->setUsers($allUsers); $organisationMapper->save($organisationEntity); - + $this->logger->info('OrganisatieService: Successfully added users to organization', [ 'organizationUuid' => $organizationUuid, 'totalUsers' => count($allUsers), 'addedUsers' => array_diff($allUsers, $currentUsers) ]); - + return true; - + } catch (\Exception $e) { $this->logger->error('OrganisatieService: Failed to add users to organization', [ 'organizationUuid' => $organizationUuid, 'error' => $e->getMessage() ]); + // Log detailed error information using PSR-3 logger + $this->logger->error('OrganisatieService: Exception details', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); return false; } } @@ -312,9 +332,9 @@ public function addUsersToOrganization(string $organizationUuid, array $username public function getAdminGroupUsernames(): array { try { - $groupManager = \OC::$server->get('OCP\IGroupManager'); + $groupManager = $this->container->get('OCP\IGroupManager'); $adminGroup = $groupManager->get('admin'); - + if ($adminGroup) { $adminUsers = $adminGroup->getUsers(); $adminUsernames = []; @@ -323,7 +343,7 @@ public function getAdminGroupUsernames(): array } return $adminUsernames; } - + return []; } catch (\Exception $e) { $this->logger->error('OrganisatieService: Failed to get admin users', [ @@ -332,4 +352,4 @@ public function getAdminGroupUsernames(): array return []; } } -} \ No newline at end of file +} diff --git a/lib/Service/OrganizationSyncService.php b/lib/Service/OrganizationSyncService.php index dc192e98..eeb83042 100644 --- a/lib/Service/OrganizationSyncService.php +++ b/lib/Service/OrganizationSyncService.php @@ -18,18 +18,23 @@ namespace OCA\SoftwareCatalog\Service; +use OCA\OpenRegister\Service\ObjectService; use OCA\SoftwareCatalog\Service\OrganisatieService; use OCA\SoftwareCatalog\Service\ContactpersoonService; +use OCA\SoftwareCatalog\Service\SoftwareCatalogue\ContactPersonHandler; use OCA\SoftwareCatalog\Service\SymfonyEmailService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IAppConfig; +use OCP\IDBConnection; use Psr\Log\LoggerInterface; /** * Service for synchronizing organizations and contact persons - * + * * This service provides comprehensive synchronization between SoftwareCatalog objects * and OpenRegister entities, ensuring data consistency and proper user management. - * + * * @category Service * @package OCA\SoftwareCatalog\Service * @author Conduction b.v. @@ -96,7 +101,9 @@ public function __construct( SymfonyEmailService $emailService, IAppConfig $config, LoggerInterface $logger, - SettingsService $settingsService + SettingsService $settingsService, + private IDBConnection $db, + private readonly ContactPersonHandler $contactpersonHandler, ) { $this->organisatieService = $organisatieService; $this->contactpersoonService = $contactpersoonService; @@ -106,6 +113,197 @@ public function __construct( $this->settingsService = $settingsService; } + public function performOrganizationsSync(int $batchSize = 50, int $maxExecutionSeconds = 45): array + { + // Check configuration + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; + $organizationSchema = $voorzieningenConfig['organisatie_schema'] ?? ''; +// $contactSchema = $voorzieningenConfig['contactpersoon_schema'] ?? ''; + + $startTime = time(); + $stats = [ + 'organizationsProcessed' => 0, + 'entitiesCreated' => 0, + 'entitiesUpdated' => 0, + 'contactPersonsProcessed' => 0, + 'usersCreated' => 0, + 'usersUpdated' => 0, + 'errors' => [], + 'batchSize' => $batchSize, + 'maxExecutionSeconds' => $maxExecutionSeconds, + 'timeoutReached' => false, + 'startTime' => date('Y-m-d H:i:s'), + 'endTime' => null, + 'duration' => null + ]; + + $qb = $this->db->getQueryBuilder(); + + $qb->select('o.uuid', $qb->createFunction('json_unquote(json_extract(o.object, \'$.status\')) as status'), 'o2.uuid as oreg_uuid', 'o2.active as active') + ->from(from: 'openregister_objects', alias: 'o') + ->leftJoin(fromAlias:'o', join: 'openregister_organisations', alias: 'o2', condition: 'o.uuid = o2.uuid') + ->where($qb->expr()->eq('o.schema', $qb->createNamedParameter($organizationSchema))) + ->andWhere($qb->expr()->eq('o.register', $qb->createNamedParameter($register))) + ->andWhere($qb->expr()->orX( + $qb->expr()->neq('o2.active', $qb->createFunction('(LOWER(json_unquote(json_extract(o.object, \'$.status\'))) = \'actief\')')), + $qb->expr()->isNull('o2.uuid') + )) + ->andWhere($qb->expr()->neq($qb->createFunction('LOWER(json_unquote(json_extract(o.object, \'$.status\')))'), $qb->createNamedParameter('concept'))) + ->orderBy('o.updated', 'ASC') // Process oldest first for consistency + ->setMaxResults($batchSize); // Limit batch size + + $sql = $qb->getSQL(); + $objects = $qb->execute()->fetchAll(); + $orgs = []; + + + foreach($objects as $object) { + // Check if we're approaching the time limit + if (time() - $startTime >= $maxExecutionSeconds) { + $stats['timeoutReached'] = true; + $this->logger->info('OrganizationSyncService: Execution time limit reached', [ + 'processedCount' => $stats['organizationsProcessed'], + 'executionTime' => time() - $startTime, + 'maxExecutionSeconds' => $maxExecutionSeconds, + 'trigger' => 'batch_processing' + ]); + break; + } + + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + if($objectService instanceOf ObjectService === false) { + return []; + } + + $object = $objectService->find(id: $object['uuid'], register: $register, schema: $organizationSchema); + + $org = $this->ensureOrganisationEntity($object,$stats); + + + } + + return $stats; + } + + public function performContactSync(int $batchSize = 100, int $maxExecutionSeconds = 30) :array + { + // Check configuration + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; +// $organizationSchema = $voorzieningenConfig['organisatie_schema'] ?? ''; + $contactSchema = $voorzieningenConfig['contactpersoon_schema'] ?? ''; + + $startTime = time(); + $stats = [ + 'organizationsProcessed' => 0, + 'entitiesCreated' => 0, + 'entitiesUpdated' => 0, + 'contactPersonsProcessed' => 0, + 'usersCreated' => 0, + 'usersUpdated' => 0, + 'errors' => [], + 'batchSize' => $batchSize, + 'maxExecutionSeconds' => $maxExecutionSeconds, + 'timeoutReached' => false, + 'startTime' => date('Y-m-d H:i:s'), + 'endTime' => null, + 'duration' => null + ]; + + $qb = $this->db->getQueryBuilder(); + + $qb->select( + 'o.uuid', + 'a.uid', + $qb->createFunction('json_unquote(json_extract(o.object, \'$.e-mailadres\')) as email'), + $qb->createFunction('json_unquote(json_extract(o.object, \'$.username\')) as username'), + 'oo.uuid as organisation' + ) + ->from('openregister_objects', 'o') + ->leftJoin( + fromAlias: 'o', + join: 'accounts_data', + alias: 'a', + condition: 'json_unquote(json_extract(o.object, \'$.e-mailadres\')) = a.value') + ->leftJoin( + fromAlias: 'o', + join: 'openregister_organisations', + alias: 'oo', + condition: 'oo.uuid = json_unquote(json_extract(o.object, \'$.organisatie\'))' + ) + ->where($qb->expr()->eq('o.register', $qb->createNamedParameter($register))) + ->andWhere($qb->expr()->eq('o.schema', $qb->createNamedParameter($contactSchema))) + ->andWhere($qb->expr()->isNull($qb->createFunction('json_unquote(json_extract(o.object, \'$.username\'))'))) + ->orderBy('o.updated', 'ASC') // Process oldest first + ->setMaxResults($batchSize); // Limit batch size + + $contacts = $qb->execute()->fetchAll(); + + foreach ($contacts as $contact) { + // Check if we're approaching the time limit + if (time() - $startTime >= $maxExecutionSeconds) { + $stats['timeoutReached'] = true; + $this->logger->info('OrganizationSyncService: Contact sync time limit reached', [ + 'contactsProcessed' => $stats['contactPersonsProcessed'], + 'executionTime' => time() - $startTime, + 'maxExecutionSeconds' => $maxExecutionSeconds, + 'trigger' => 'batch_processing' + ]); + break; + } + + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + $contactEntity = $objectService->find(id: $contact['uuid'], register: $register, schema: $contactSchema); + $contactEntityObject = $contactEntity->getObject(); + + if ($contact['organisation'] === null) { + continue; + } + + $contactEntityObject['username'] = $contact['uid']; + + if ($contact['uid'] === null) { + $user = $this->contactpersonHandler->createUserAccount($contactEntity); + $contactEntityObject['username'] = $user->getUID(); + } + + $contactEntity->setObject($contactEntityObject); + $objectService->saveObject(object: $contactEntity, register: $register, schema: $contactSchema, rbac: false, multi: false); + + $stats['contactPersonsProcessed']++; + } + + return $stats; + } + + + public function performUserSync(): array + { + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; +// $organizationSchema = $voorzieningenConfig['organisatie_schema'] ?? ''; + $contactSchema = $voorzieningenConfig['contactpersoon_schema'] ?? ''; + + + $qb = $this->db->getQueryBuilder(); + $qb->select('o.uuid', $qb->createFunction('json_unquote(json_extract(`o`.`object`, \'$.username\')) as username'), $qb->createFunction('json_unquote(json_extract(o.object, \'$.organisatie\')) as organisation'), 'oo.users') + ->from(from:'openregister_objects', alias: 'o') + ->leftJoin(fromAlias: 'o', join: 'openregister_organisations', alias: 'oo', condition: 'oo.uuid = json_unquote(json_extract(o.object, \'$.organisatie\'))') + ->where($qb->expr()->eq('o.register', $qb->createNamedParameter($register))) + ->andWhere($qb->expr()->orX('o.schema', $qb->createNamedParameter($contactSchema))) + ->andWhere($qb->expr()->eq($qb->createFunction('json_contains(oo.users, json_extract(`o`.`object`, \'$.username\'))'), $qb->createNamedParameter(0))); + + $sql = $qb->getSQL(); + $users = $qb->execute()->fetchAll(); + foreach($users as $user) { + $this->organisatieService->addUsersToOrganization($user['organisation'], [$user['username']]); + } + + return []; + } + + /** * Performs comprehensive organization and contact person synchronization * @@ -113,7 +311,7 @@ public function __construct( * the specified time window with organisation entities. * * @param int $minutesBack Number of minutes to look back for changes (0 = all objects) - * + * * @return array Synchronization results and statistics */ public function performFullSync(int $minutesBack = 10): array @@ -122,7 +320,7 @@ public function performFullSync(int $minutesBack = 10): array 'minutesBack' => $minutesBack, 'syncMode' => $minutesBack === 0 ? 'full' : 'incremental' ]); - + $stats = [ 'organizationsProcessed' => 0, 'entitiesCreated' => 0, @@ -179,7 +377,7 @@ public function performFullSync(int $minutesBack = 10): array $stats['duration'] = $endTime->diff($startTime)->format('%H:%I:%S'); $this->logger->info('OrganizationSyncService: Completed comprehensive synchronization', $stats); - + return $stats; } catch (\Exception $e) { @@ -199,14 +397,14 @@ public function performFullSync(int $minutesBack = 10): array * @param string $register The register ID * @param string $organizationSchema The organization schema ID * @param int $minutesBack Number of minutes to look back (0 = all objects) - * + * * @return array Array of organisatie objects */ private function getOrganisatieObjectsByTimeWindow(string $register, string $organizationSchema, int $minutesBack): array { try { $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); - + // Build base query for register and schema $query = [ '@self' => [ @@ -214,17 +412,17 @@ private function getOrganisatieObjectsByTimeWindow(string $register, string $org 'schema' => (int) $organizationSchema ] ]; - + // Add time-based filtering if minutesBack > 0 if ($minutesBack > 0) { $cutoffTime = new \DateTime(); $cutoffTime->sub(new \DateInterval('PT' . $minutesBack . 'M')); $cutoffTimeString = $cutoffTime->format('Y-m-d\TH:i:sP'); - + // Add time filtering to the query // Filter objects that were updated within the time window $query['@self']['updated'] = ['gte' => $cutoffTimeString]; - + $this->logger->debug('OrganizationSyncService: Using searchObjects with time-based filtering', [ 'register' => $register, 'schema' => $organizationSchema, @@ -241,10 +439,10 @@ private function getOrganisatieObjectsByTimeWindow(string $register, string $org 'query' => $query ]); } - + // Use searchObjects method for filtering $objects = $objectService->searchObjects($query); - + $this->logger->debug('OrganizationSyncService: Retrieved organisatie objects with searchObjects', [ 'register' => $register, 'schema' => $organizationSchema, @@ -261,7 +459,7 @@ private function getOrganisatieObjectsByTimeWindow(string $register, string $org 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + return []; } } @@ -328,88 +526,132 @@ private function ensureOrganisationEntity(object $organisatieObject, array &$sta try { $objectData = $organisatieObject->getObject(); $organisatieId = $objectData['id'] ?? $organisatieObject->getId(); - + + $this->logger->critical('šŸ” ENSURING ORGANISATION ENTITY', [ + 'app' => 'softwarecatalog', + 'organisatieId' => $organisatieId, + 'naam' => $objectData['naam'] ?? 'Unknown', + 'status' => $objectData['status'] ?? 'Unknown' + ]); + + // Get configuration for object updates + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; + $organizationSchema = $voorzieningenConfig['organisatie_schema'] ?? ''; + // Try to find existing organisation entity $organisationMapper = \OC::$server->get('OCA\OpenRegister\Db\OrganisationMapper'); - + try { $organisationEntity = $organisationMapper->findByUuid($organisatieId); - + // Entity exists - update it if needed - $beoordeling = strtolower($objectData['beoordeling'] ?? 'actief'); - $shouldBeActive = in_array($beoordeling, ['actief', 'active']); - + $status = strtolower($objectData['status'] ?? 'actief'); + $shouldBeActive = in_array($status, ['actief', 'active']); + + $this->logger->critical('šŸ“‹ EXISTING ENTITY FOUND', [ + 'app' => 'softwarecatalog', + 'organisatieId' => $organisatieId, + 'entityId' => $organisationEntity->getId(), + 'currentActive' => $organisationEntity->getActive(), + 'shouldBeActive' => $shouldBeActive, + 'needsUpdate' => $organisationEntity->getActive() !== $shouldBeActive + ]); + if ($organisationEntity->getActive() !== $shouldBeActive) { - $this->logger->info('OrganizationSyncService: Updating organisation entity status', [ + $this->logger->critical('šŸ”„ UPDATING ENTITY STATUS', [ + 'app' => 'softwarecatalog', 'organisatieId' => $organisatieId, 'oldActive' => $organisationEntity->getActive(), 'newActive' => $shouldBeActive ]); - + + $wasActive = $organisationEntity->getActive(); $organisationEntity->setActive($shouldBeActive); $organisationMapper->save($organisationEntity); $stats['entitiesUpdated']++; // Send activation email if organization became active - if ($shouldBeActive && !$organisationEntity->getActive()) { + if ($shouldBeActive && !$wasActive) { + $this->logger->info('[FLOW] Sending organization activation email', [ + 'organisatieId' => $organisatieId + ]); $emailSent = $this->sendOrganizationActivationEmail($objectData); if ($emailSent) { - $this->logger->info('OrganizationSyncService: Organization activation email sent successfully', [ + $this->logger->info('šŸ“§ Organization activation email sent successfully', [ 'organisatieId' => $organisatieId ]); } else { - $this->logger->info('OrganizationSyncService: Organization activation email not sent (disabled or not configured)', [ + $this->logger->info('šŸ“§ Organization activation email not sent (disabled or not configured)', [ 'organisatieId' => $organisatieId ]); } } } - - $this->logger->debug('OrganizationSyncService: Found existing organisation entity', [ - 'organisatieId' => $organisatieId, - 'entityId' => $organisationEntity->getId(), - 'active' => $organisationEntity->getActive() - ]); + + // Update organisatie object owner to organisation entity UUID + $this->updateOrganisatieObjectOwner($organisatieObject, $organisationEntity, $register, $organizationSchema); + return $organisationEntity; + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { // Entity doesn't exist, create it - $this->logger->info('OrganizationSyncService: Creating new organisation entity', [ - 'organisatieId' => $organisatieId + $this->logger->critical('šŸ†• CREATING NEW ORGANISATION ENTITY', [ + 'app' => 'softwarecatalog', + 'organisatieId' => $organisatieId, + 'naam' => $objectData['naam'] ?? 'Unknown' ]); - + $organisationEntity = $this->organisatieService->createOrganisationInOpenRegister($objectData); if ($organisationEntity) { $stats['entitiesCreated']++; - $this->logger->info('OrganizationSyncService: Successfully created organisation entity', [ + $this->logger->critical('šŸŽŠ NEW ORGANISATION ENTITY CREATED', [ + 'app' => 'softwarecatalog', 'organisatieId' => $organisatieId, 'entityId' => $organisationEntity->getId(), - 'active' => $organisationEntity->getActive() + 'active' => $organisationEntity->getActive(), + 'title' => $organisationEntity->getTitle() ]); // Send registration email for new organization + $this->logger->info('[FLOW] Sending organization registration email', [ + 'organisatieId' => $organisatieId + ]); $emailSent = $this->sendOrganizationRegistrationEmail($objectData); if ($emailSent) { - $this->logger->info('OrganizationSyncService: Organization registration email sent successfully', [ + $this->logger->info('šŸ“§ Organization registration email sent successfully', [ 'organisatieId' => $organisatieId ]); } else { - $this->logger->info('OrganizationSyncService: Organization registration email not sent (disabled or not configured)', [ + $this->logger->info('šŸ“§ Organization registration email not sent (disabled or not configured)', [ 'organisatieId' => $organisatieId ]); } + + // Update organisatie object owner to organisation entity UUID + $this->updateOrganisatieObjectOwner($organisatieObject, $organisationEntity, $register, $organizationSchema); + } else { + $this->logger->error('āŒ ORGANISATION ENTITY CREATION FAILED', [ + 'app' => 'softwarecatalog', + 'organisatieId' => $organisatieId + ]); } return $organisationEntity; } - + } catch (\Exception $e) { - $this->logger->error('OrganizationSyncService: Failed to ensure organisation entity', [ + $this->logger->error('šŸ’„ ENSURE ORGANISATION ENTITY EXCEPTION', [ + 'app' => 'softwarecatalog', 'organisatieId' => $organisatieObject->getId(), - 'exception' => $e + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() ]); return null; } } - + /** * Safely sends organization registration email with error handling * @@ -429,7 +671,7 @@ private function sendOrganizationRegistrationEmail(array $organizationData): boo return false; } } - + /** * Safely sends organization activation email with error handling * @@ -468,7 +710,7 @@ private function getContactPersonsForOrganisation(string $organisatieId, string try { $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); - + // Use searchObjects for more efficient filtering on-demand $query = [ '@self' => [ @@ -477,7 +719,7 @@ private function getContactPersonsForOrganisation(string $organisatieId, string ], 'organisatie' => $organisatieId ]; - + $contactPersons = $objectService->searchObjects($query); $this->logger->debug('OrganizationSyncService: Retrieved contact persons on-demand', [ @@ -585,7 +827,7 @@ private function updateOrganisationEntityUsers(object $organisationEntity, array try { $organisationUuid = $organisationEntity->getUuid(); $currentUsers = $organisationEntity->getUsers() ?? []; - + // Add admin users to ensure they're always included $adminUsers = $this->getAdminUsers(); $allUsernames = array_unique(array_merge($usernames, $adminUsers)); @@ -605,12 +847,12 @@ private function updateOrganisationEntityUsers(object $organisationEntity, array ]); $organisationEntity->setUsers($allUsernames); - + $organisationMapper = \OC::$server->get('OCA\OpenRegister\Db\OrganisationMapper'); $organisationMapper->save($organisationEntity); - + $stats['entitiesUpdated']++; - + $this->logger->info('OrganizationSyncService: Successfully updated organisation entity users', [ 'organisationUuid' => $organisationUuid, 'totalUsers' => count($allUsernames) @@ -640,7 +882,7 @@ private function getAdminUsers(): array try { $groupManager = \OC::$server->get('OCP\IGroupManager'); $adminGroup = $groupManager->get('admin'); - + if ($adminGroup) { $adminUsers = $adminGroup->getUsers(); $adminUsernames = []; @@ -649,7 +891,7 @@ private function getAdminUsers(): array } return $adminUsernames; } - + return []; } catch (\Exception $e) { $this->logger->error('OrganizationSyncService: Failed to get admin users', [ @@ -663,7 +905,7 @@ private function getAdminUsers(): array * Performs a quick sync status check with prediction of objects to be processed * * @param int $minutesBack Number of minutes to look back for prediction (default: 10 for scheduled sync) - * + * * @return array Status information about sync requirements including processing predictions */ public function getSyncStatus(int $minutesBack = 10): array @@ -684,10 +926,10 @@ public function getSyncStatus(int $minutesBack = 10): array // Get total counts (all objects) $allOrganisatieObjects = $this->getOrganisatieObjectsByTimeWindow($register, $organizationSchema, 0); - + // Get incremental counts (objects to be processed in next sync) $incrementalOrganisatieObjects = $this->getOrganisatieObjectsByTimeWindow($register, $organizationSchema, $minutesBack); - + // Get organization entities count $organisationMapper = \OC::$server->get('OCA\OpenRegister\Db\OrganisationMapper'); $entitiesCount = 0; @@ -710,7 +952,7 @@ public function getSyncStatus(int $minutesBack = 10): array } // Calculate efficiency metrics - $efficiencyImprovement = count($allOrganisatieObjects) > 0 + $efficiencyImprovement = count($allOrganisatieObjects) > 0 ? round((1 - (count($incrementalOrganisatieObjects) / count($allOrganisatieObjects))) * 100, 1) : 0; @@ -718,31 +960,31 @@ public function getSyncStatus(int $minutesBack = 10): array 'configured' => true, 'syncMode' => $minutesBack === 0 ? 'full' : 'incremental', 'timeWindow' => $minutesBack, - + // Total counts 'totalOrganizationObjects' => count($allOrganisatieObjects), 'totalOrganizationEntities' => $entitiesCount, - + // Processing predictions 'organizationsToProcess' => count($incrementalOrganisatieObjects), 'contactPersonsToProcess' => $predictedContactPersonsToProcess, - + // Efficiency metrics 'efficiencyImprovement' => $efficiencyImprovement . '%', 'processingReduction' => count($allOrganisatieObjects) - count($incrementalOrganisatieObjects), - + // Configuration 'contactSchemaConfigured' => !empty($contactSchema), 'lastSyncTime' => $this->config->getValueString('softwarecatalog', 'last_sync_time', 'Never'), - + // Email configuration status 'emailStatus' => $this->getEmailConfigurationStatus(), - + // Status messages - 'message' => count($incrementalOrganisatieObjects) > 0 + 'message' => count($incrementalOrganisatieObjects) > 0 ? "Ready to process {$this->formatNumber(count($incrementalOrganisatieObjects))} organizations and {$this->formatNumber($predictedContactPersonsToProcess)} contact persons" : 'No organizations to process in the current time window', - 'nextScheduledSync' => $minutesBack > 0 + 'nextScheduledSync' => $minutesBack > 0 ? "Will process organizations updated in the last {$minutesBack} minutes" : 'Will process all organizations (full sync)' ]; @@ -781,7 +1023,7 @@ private function getEmailConfigurationStatus(): array * Format numbers for better readability * * @param int $number The number to format - * + * * @return string Formatted number */ private function formatNumber(int $number): string @@ -802,33 +1044,672 @@ public function recordSyncTime(): void $this->config->setValueString('softwarecatalog', 'last_sync_time', date('Y-m-d H:i:s')); } + /** + * Process a specific organization object (called from event listener) + * + * This method processes a single organization object, creating or updating + * the corresponding organization entity as needed. + * + * @param \OCA\OpenRegister\Db\ObjectEntity $organizationObject The organization object to process + * + * @return array Processing results + */ + public function processSpecificOrganization($organizationObject): array + { + $startTime = microtime(true); + $stats = [ + 'organizationsProcessed' => 0, + 'entitiesCreated' => 0, + 'entitiesUpdated' => 0, + 'contactPersonsProcessed' => 0, + 'usersCreated' => 0, + 'errors' => [], + 'startTime' => date('Y-m-d H:i:s') + ]; + + try { + $objectData = $organizationObject->getObject(); + $organizationUuid = $organizationObject->getUuid(); + + $this->logger->critical('šŸ¢ ORGANIZATION PROCESSING STARTED', [ + 'app' => 'softwarecatalog', + 'trigger' => 'ObjectCreatedEvent', + 'organizationId' => $organizationUuid, + 'organizationName' => $objectData['naam'] ?? 'Unknown', + 'organizationStatus' => $objectData['status'] ?? 'Unknown', + 'timestamp' => date('Y-m-d H:i:s'), + 'microtime' => microtime(true) + ]); + + + + // Process organization entity + $this->logger->info('[FLOW] Step 1: Creating/updating organisation entity', [ + 'organizationId' => $organizationUuid, + 'action' => 'ensure_organisation_entity' + ]); + + $organisationEntity = $this->ensureOrganisationEntity($organizationObject, $stats); + + if ($organisationEntity) { + $stats['organizationsProcessed']++; + + $this->logger->critical('āœ… ORGANISATION ENTITY CREATED/UPDATED', [ + 'app' => 'softwarecatalog', + 'organizationUuid' => $organizationUuid, + 'entityId' => $organisationEntity->getId(), + 'entityActive' => $organisationEntity->getActive(), + 'entitiesCreated' => $stats['entitiesCreated'], + 'entitiesUpdated' => $stats['entitiesUpdated'] + ]); + + // Step 2: Find and process related contactpersonen objects (separate objects, not nested) + $this->logger->info('[FLOW] Step 2: Finding related contactpersoon objects', [ + 'organizationId' => $organizationUuid, + 'action' => 'process_related_contactpersonen' + ]); + + $this->processRelatedContactPersons($organizationUuid, $stats); + + } else { + $this->logger->error('āŒ ORGANISATION ENTITY FAILED', [ + 'app' => 'softwarecatalog', + 'organizationUuid' => $organizationUuid, + 'error' => 'Failed to create/update organisation entity' + ]); + $stats['errors'][] = 'Failed to create/update organisation entity'; + } + + $stats['endTime'] = date('Y-m-d H:i:s'); + $stats['duration'] = round(microtime(true) - $startTime, 3); + + $this->logger->critical('šŸ ORGANIZATION PROCESSING COMPLETED', [ + 'app' => 'softwarecatalog', + 'organizationId' => $organizationUuid, + 'stats' => $stats, + 'processingTime' => $stats['duration'] . 's' + ]); + + return $stats; + + } catch (\Exception $e) { + $stats['errors'][] = $e->getMessage(); + $this->logger->error('šŸ’„ ORGANIZATION PROCESSING EXCEPTION', [ + 'app' => 'softwarecatalog', + 'organizationId' => $organizationObject->getUuid(), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); + + return $stats; + } + } + + /** + * Process nested contact persons within an organization object + * + * @param \OCA\OpenRegister\Db\ObjectEntity $organizationObject The organization object containing contact persons + * @param array $stats The statistics array to update + * @return void + */ + private function processNestedContactPersons($organizationObject, array &$stats): void + { + try { + $objectData = $organizationObject->getObject(); + $organizationUuid = $organizationObject->getUuid(); + + // Check if organization has nested contact persons + $contactPersons = $objectData['contactpersonen'] ?? $objectData['contactPersons'] ?? []; + + if (empty($contactPersons)) { + $this->logger->info('[FLOW] No nested contact persons found in organization', [ + 'organizationId' => $organizationUuid + ]); + return; + } + + $this->logger->critical('šŸ‘„ PROCESSING NESTED CONTACT PERSONS', [ + 'app' => 'softwarecatalog', + 'organizationId' => $organizationUuid, + 'contactCount' => count($contactPersons) + ]); + + // Get configuration for contact person creation + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; + $contactSchema = $voorzieningenConfig['contactpersoon_schema'] ?? ''; + + if (empty($register) || empty($contactSchema)) { + $this->logger->warning('[FLOW] Contact person processing skipped - configuration missing', [ + 'organizationId' => $organizationUuid, + 'register' => $register, + 'contactSchema' => $contactSchema + ]); + return; + } + + foreach ($contactPersons as $index => $contactData) { + try { + $this->logger->info('[FLOW] Processing nested contact person', [ + 'organizationId' => $organizationUuid, + 'contactIndex' => $index, + 'contactEmail' => $contactData['email'] ?? $contactData['e-mailadres'] ?? 'unknown' + ]); + + // Create contact person object in OpenRegister if it doesn't exist + $this->createOrUpdateContactPersonObject($contactData, $organizationUuid, $register, $contactSchema, $stats); + + } catch (\Exception $e) { + $this->logger->error('[FLOW] Failed to process nested contact person', [ + 'organizationId' => $organizationUuid, + 'contactIndex' => $index, + 'exception' => $e->getMessage() + ]); + $stats['errors'][] = "Contact person {$index}: " . $e->getMessage(); + } + } + + } catch (\Exception $e) { + $this->logger->error('[FLOW] Failed to process nested contact persons', [ + 'organizationId' => $organizationObject->getUuid(), + 'exception' => $e->getMessage() + ]); + $stats['errors'][] = 'Failed to process nested contact persons: ' . $e->getMessage(); + } + } + + /** + * Process related contactpersoon objects that have this organization in their organisation property + * + * @param string $organizationUuid The organization UUID to find related contacts for + * @param array $stats The statistics array to update + * @return void + */ + private function processRelatedContactPersons(string $organizationUuid, array &$stats): void + { + try { + $this->logger->critical('šŸ” FINDING RELATED CONTACT PERSONS', [ + 'app' => 'softwarecatalog', + 'organizationId' => $organizationUuid, + 'action' => 'find_related_contacts' + ]); + + // Get configuration + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; + $contactSchema = $voorzieningenConfig['contactpersoon_schema'] ?? ''; + + if (empty($register) || empty($contactSchema)) { + $this->logger->warning('[FLOW] Related contact processing skipped - configuration missing', [ + 'organizationId' => $organizationUuid, + 'register' => $register, + 'contactSchema' => $contactSchema + ]); + return; + } + + // Find all contactpersoon objects that have this organization in their organisation property + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + + // Search for contactpersoon objects with this organization reference + $query = [ + '@self' => [ + 'register' => (int) $register, + 'schema' => (int) $contactSchema + ], + 'organisatie' => $organizationUuid + ]; + + $this->logger->info('[FLOW] Searching for related contact persons', [ + 'organizationId' => $organizationUuid, + 'query' => $query + ]); + + $relatedContacts = $objectService->searchObjects($query); + + if (empty($relatedContacts)) { + $this->logger->info('[FLOW] No related contact persons found', [ + 'organizationId' => $organizationUuid + ]); + return; + } + + $this->logger->critical('šŸ‘„ PROCESSING RELATED CONTACT PERSONS', [ + 'app' => 'softwarecatalog', + 'organizationId' => $organizationUuid, + 'contactCount' => count($relatedContacts) + ]); + + foreach ($relatedContacts as $contactObject) { + try { + $contactUuid = $contactObject->getUuid(); + $contactData = $contactObject->getObject(); + + $this->logger->info('[FLOW] Processing related contact person', [ + 'organizationId' => $organizationUuid, + 'contactId' => $contactUuid, + 'contactEmail' => $contactData['email'] ?? $contactData['e-mailadres'] ?? 'unknown' + ]); + + // Process the contact person through processSpecificContactPerson + $contactStats = $this->processSpecificContactPerson($contactObject); + + // Merge stats + $stats['contactPersonsProcessed'] += $contactStats['contactPersonsProcessed'] ?? 0; + $stats['usersCreated'] += $contactStats['usersCreated'] ?? 0; + $stats['usersUpdated'] += $contactStats['usersUpdated'] ?? 0; + if (!empty($contactStats['errors'])) { + $stats['errors'] = array_merge($stats['errors'], $contactStats['errors']); + } + + } catch (\Exception $e) { + $this->logger->error('[FLOW] Failed to process related contact person', [ + 'organizationId' => $organizationUuid, + 'contactId' => $contactObject->getUuid(), + 'exception' => $e->getMessage() + ]); + $stats['errors'][] = "Related contact {$contactObject->getUuid()}: " . $e->getMessage(); + } + } + + } catch (\Exception $e) { + $this->logger->error('[FLOW] Failed to process related contact persons', [ + 'organizationId' => $organizationUuid, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + $stats['errors'][] = 'Failed to process related contact persons: ' . $e->getMessage(); + } + } + + /** + * Create or update a contact person object and 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 + * @return void + */ + private function createOrUpdateContactPersonObject(array $contactData, string $organizationUuid, string $register, string $contactSchema, array &$stats): void + { + try { + // Ensure contact data has organization reference + $contactData['organisatie'] = $organizationUuid; + + $email = $contactData['email'] ?? $contactData['e-mailadres'] ?? ''; + if (empty($email)) { + $this->logger->warning('[FLOW] Contact person has no email, skipping', [ + 'organizationId' => $organizationUuid, + 'contactData' => array_keys($contactData) + ]); + return; + } + + $this->logger->critical('šŸ“§ CREATING CONTACT PERSON OBJECT', [ + 'app' => 'softwarecatalog', + 'organizationId' => $organizationUuid, + 'email' => $email, + 'name' => ($contactData['voornaam'] ?? '') . ' ' . ($contactData['achternaam'] ?? '') + ]); + + // Create the contact person object in OpenRegister + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + $contactObject = $objectService->saveObject( + object: $contactData, + register: $register, + schema: $contactSchema, + rbac: false, + multi: false + ); + + if ($contactObject) { + $stats['contactPersonsProcessed']++; + + $this->logger->critical('āœ… CONTACT PERSON OBJECT CREATED', [ + 'app' => 'softwarecatalog', + 'organizationId' => $organizationUuid, + 'contactId' => $contactObject->getUuid(), + 'email' => $email + ]); + + // Create user account if username is missing AND organization is active + $contactObjectData = $contactObject->getObject(); + if (empty($contactObjectData['username'])) { + // Check if organization exists in organisation entity table (only active orgs have entries) + try { + $organisationMapper = \OC::$server->get('OCA\OpenRegister\Db\OrganisationMapper'); + $organisationEntity = $organisationMapper->findByUuid($organizationUuid); + + if ($organisationEntity && $organisationEntity->getActive()) { + $this->logger->critical('šŸ‘¤ CREATING USER ACCOUNT (org is active)', [ + 'app' => 'softwarecatalog', + 'contactId' => $contactObject->getUuid(), + 'organizationId' => $organizationUuid, + 'organizationActive' => true, + 'email' => $email + ]); + + $user = $this->contactpersonHandler->createUserAccount($contactObject); + if ($user) { + $stats['usersCreated']++; + $contactObjectData['username'] = $user->getUID(); + + // Update the contact object with username + $contactObject->setObject($contactObjectData); + $objectService->saveObject( + object: $contactObject, + register: $register, + schema: $contactSchema, + rbac: false, + multi: false + ); + + // Add user to organization entity in database + $this->contactpersonHandler->addUserToOrganizationEntity($contactObject, $user->getUID()); + + // Update contactpersoon object owner to user UID + $this->updateContactpersoonObjectOwner($contactObject, $user->getUID(), $register, $contactSchema); + + $this->logger->critical('šŸŽ‰ USER ACCOUNT CREATED SUCCESS', [ + 'app' => 'softwarecatalog', + 'contactId' => $contactObject->getUuid(), + 'username' => $user->getUID(), + 'email' => $email, + 'displayName' => $user->getDisplayName() + ]); + } else { + $this->logger->error('āŒ USER ACCOUNT CREATION FAILED', [ + 'app' => 'softwarecatalog', + 'contactId' => $contactObject->getUuid(), + 'email' => $email + ]); + $stats['errors'][] = "Failed to create user account for {$email}"; + } + } else { + $this->logger->info('Skipping user creation - organization not active or not found in entity table', [ + 'contactId' => $contactObject->getUuid(), + 'organizationId' => $organizationUuid, + 'organizationFound' => $organisationEntity !== null, + 'organizationActive' => $organisationEntity ? $organisationEntity->getActive() : false, + '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 + ]); + } + } + } + + } catch (\Exception $e) { + $this->logger->error('[FLOW] Failed to create/update contact person object', [ + 'organizationId' => $organizationUuid, + 'email' => $contactData['email'] ?? 'unknown', + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + $stats['errors'][] = "Contact person creation failed: " . $e->getMessage(); + } + } + + /** + * Process a specific contact person object (called from event listener) + * + * This method processes a single contact person object, creating user accounts + * and updating the contact person object as needed. + * + * @param \OCA\OpenRegister\Db\ObjectEntity $contactObject The contact person object to process + * + * @return array Processing results + */ + public function processSpecificContactPerson($contactObject): array + { + $stats = [ + 'contactPersonsProcessed' => 0, + 'usersCreated' => 0, + 'usersUpdated' => 0, + 'errors' => [], + 'startTime' => date('Y-m-d H:i:s') + ]; + + try { + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $register = $voorzieningenConfig['register'] ?? ''; + $contactSchema = $voorzieningenConfig['contactpersoon_schema'] ?? ''; + + $this->logger->info('[EVENT] OrganizationSyncService: Processing specific contact person', [ + 'contactId' => $contactObject->getUuid(), + 'trigger' => 'event_listener' + ]); + + $contactEntityObject = $contactObject->getObject(); + + // Skip if no organization reference + $organizationUuid = $contactEntityObject['organisatie'] ?? null; + if (empty($organizationUuid)) { + $this->logger->warning('[EVENT] OrganizationSyncService: Contact person has no organization reference', [ + 'contactId' => $contactObject->getUuid() + ]); + return $stats; + } + + // Create user account if username is missing AND organization is active + if (empty($contactEntityObject['username'])) { + // Check if organization exists in organisation entity table (only active orgs have entries) + try { + $organisationMapper = \OC::$server->get('OCA\OpenRegister\Db\OrganisationMapper'); + $organisationEntity = $organisationMapper->findByUuid($organizationUuid); + + if ($organisationEntity && $organisationEntity->getActive()) { + $this->logger->info('[EVENT] OrganizationSyncService: Creating user account for contact person (org is active)', [ + 'contactId' => $contactObject->getUuid(), + 'organizationId' => $organizationUuid, + 'organizationActive' => true, + 'email' => $contactEntityObject['email'] ?? $contactEntityObject['e-mailadres'] ?? 'unknown' + ]); + + $user = $this->contactpersonHandler->createUserAccount($contactObject); + $contactEntityObject['username'] = $user->getUID(); + + // Update the contact object with the username (using RBAC bypass) + $contactObject->setObject($contactEntityObject); + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + $objectService->saveObject( + object: $contactObject, + register: $register, + schema: $contactSchema, + rbac: false, + multi: false + ); + + // Add user to organization entity in database + $this->contactpersonHandler->addUserToOrganizationEntity($contactObject, $user->getUID()); + + // Update contactpersoon object owner to user UID + $this->updateContactpersoonObjectOwner($contactObject, $user->getUID(), $register, $contactSchema); + + $stats['usersCreated']++; + } else { + $this->logger->info('[EVENT] OrganizationSyncService: Skipping user creation - organization not active or not found in entity table', [ + 'contactId' => $contactObject->getUuid(), + 'organizationId' => $organizationUuid, + 'organizationFound' => $organisationEntity !== null, + 'organizationActive' => $organisationEntity ? $organisationEntity->getActive() : false, + 'email' => $contactEntityObject['email'] ?? $contactEntityObject['e-mailadres'] ?? 'unknown' + ]); + } + } catch (\Exception $e) { + // Organization not found in entity table = not active + $this->logger->info('[EVENT] OrganizationSyncService: Skipping user creation - organization not found in entity table (not active)', [ + 'contactId' => $contactObject->getUuid(), + 'organizationId' => $organizationUuid, + 'reason' => 'Organization not found in entity table', + 'email' => $contactEntityObject['email'] ?? $contactEntityObject['e-mailadres'] ?? 'unknown' + ]); + } + } + + $stats['contactPersonsProcessed']++; + $stats['endTime'] = date('Y-m-d H:i:s'); + $stats['duration'] = (new \DateTime($stats['endTime']))->getTimestamp() - (new \DateTime($stats['startTime']))->getTimestamp(); + + $this->logger->info('[EVENT] OrganizationSyncService: Specific contact person processing completed', [ + 'contactId' => $contactObject->getUuid(), + 'stats' => $stats + ]); + + return $stats; + + } catch (\Exception $e) { + $stats['errors'][] = $e->getMessage(); + $this->logger->error('[EVENT] OrganizationSyncService: Failed to process specific contact person', [ + 'contactId' => $contactObject->getUuid(), + 'exception' => $e->getMessage() + ]); + + return $stats; + } + } + + /** + * Performs optimized manual synchronization for large datasets + * + * This method processes organizations in multiple batches to handle large numbers + * (800+) efficiently while staying under timeout limits. + * + * @param int $maxRounds Maximum number of batch rounds to execute (default: 10) + * @param int $batchSize Number of items to process per batch (default: 100) + * + * @return array Comprehensive synchronization results + */ + public function performOptimizedManualSync(int $maxRounds = 10, int $batchSize = 100): array + { + $totalStartTime = time(); + $allResults = [ + 'totalRounds' => 0, + 'organizationsProcessed' => 0, + 'contactPersonsProcessed' => 0, + 'entitiesCreated' => 0, + 'entitiesUpdated' => 0, + 'usersCreated' => 0, + 'usersUpdated' => 0, + 'totalExecutionTime' => 0, + 'timeoutReached' => false, + 'roundsCompleted' => [], + 'errors' => [], + 'startTime' => date('Y-m-d H:i:s') + ]; + + $this->logger->info('[MANUAL] OrganizationSyncService: Starting optimized manual sync', [ + 'maxRounds' => $maxRounds, + 'batchSize' => $batchSize, + 'trigger' => 'manual' + ]); + + for ($round = 1; $round <= $maxRounds; $round++) { + $roundStartTime = time(); + + // Process organizations batch + $orgResults = $this->performOrganizationsSync($batchSize, 45); + + // Process contacts batch + $contactResults = $this->performContactSync($batchSize, 15); + + // Accumulate results + $allResults['organizationsProcessed'] += $orgResults['organizationsProcessed']; + $allResults['contactPersonsProcessed'] += $contactResults['contactPersonsProcessed']; + $allResults['entitiesCreated'] += $orgResults['entitiesCreated']; + $allResults['entitiesUpdated'] += $orgResults['entitiesUpdated']; + $allResults['usersCreated'] += $contactResults['usersCreated']; + $allResults['usersUpdated'] += $contactResults['usersUpdated']; + + $roundTime = time() - $roundStartTime; + $allResults['roundsCompleted'][] = [ + 'round' => $round, + 'organizationsProcessed' => $orgResults['organizationsProcessed'], + 'contactPersonsProcessed' => $contactResults['contactPersonsProcessed'], + 'duration' => $roundTime, + 'orgTimeoutReached' => $orgResults['timeoutReached'] ?? false, + 'contactTimeoutReached' => $contactResults['timeoutReached'] ?? false + ]; + + // If no items were processed in this round, we're done + if ($orgResults['organizationsProcessed'] === 0 && $contactResults['contactPersonsProcessed'] === 0) { + $this->logger->info('[MANUAL] OrganizationSyncService: No more items to process, stopping', [ + 'round' => $round, + 'totalProcessed' => $allResults['organizationsProcessed'] + $allResults['contactPersonsProcessed'] + ]); + break; + } + + $allResults['totalRounds'] = $round; + + // Small pause between rounds to prevent resource exhaustion + if ($round < $maxRounds) { + sleep(1); + } + } + + // Final user sync + $this->performUserSync(); + $this->recordSyncTime(); + + $allResults['totalExecutionTime'] = time() - $totalStartTime; + $allResults['endTime'] = date('Y-m-d H:i:s'); + + $this->logger->info('[MANUAL] OrganizationSyncService: Optimized manual sync completed', $allResults); + + return $allResults; + } + /** * Performs scheduled synchronization with comprehensive logging * * This method is designed to be called by the background job and includes * all necessary logging, error handling, and status tracking. - * Uses default 10-minute lookback for incremental sync. + * Uses default full sync (0 minutes) to process all organizations. + * + * @param int $minutesBack Number of minutes to look back for changes (default: 0 = full sync) * - * @param int $minutesBack Number of minutes to look back for changes (default: 10) - * * @return array Synchronization results with detailed logging information */ - public function performScheduledSync(int $minutesBack = 10): array + public function performScheduledSync(int $minutesBack = 0): array { - $this->logger->info('OrganizationSyncService: Starting scheduled synchronization', [ + $this->logger->info('[CRONJOB] OrganizationSyncService: Starting scheduled synchronization', [ 'minutesBack' => $minutesBack, - 'syncMode' => $minutesBack === 0 ? 'full' : 'incremental' + 'syncMode' => $minutesBack === 0 ? 'full' : 'incremental', + 'trigger' => 'cronjob' ]); try { - // Perform the core synchronization with time-based filtering - $syncResults = $this->performFullSync($minutesBack); - + // Perform optimized batch synchronization + // Use smaller batches for scheduled sync to ensure it completes within time limits + $orgBatchSize = 25; // Conservative batch size for organizations + $contactBatchSize = 50; // Larger batch size for contacts (faster processing) + $maxOrgTime = 30; // 30 seconds max for organizations + $maxContactTime = 15; // 15 seconds max for contacts + + $syncResults = $this->performOrganizationsSync($orgBatchSize, $maxOrgTime); + + $contactResults = $this->performContactSync($contactBatchSize, $maxContactTime); + $syncResults = array_merge($contactResults, $syncResults); + + $this->performUserSync(); // Record the sync time $this->recordSyncTime(); // Log summary results - $this->logger->info('OrganizationSyncService: Scheduled synchronization completed', [ + $this->logger->info('[CRONJOB] OrganizationSyncService: Scheduled synchronization completed', [ 'organizationsProcessed' => $syncResults['organizationsProcessed'], 'entitiesCreated' => $syncResults['entitiesCreated'], 'entitiesUpdated' => $syncResults['entitiesUpdated'], @@ -849,13 +1730,13 @@ public function performScheduledSync(int $minutesBack = 10): array return $syncResults; } catch (\Exception $e) { - $this->logger->error('OrganizationSyncService: Scheduled synchronization failed', [ + $this->logger->error('[CRONJOB] OrganizationSyncService: Scheduled synchronization failed', [ 'exception' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString() ]); - + return [ 'organizationsProcessed' => 0, 'entitiesCreated' => 0, @@ -879,7 +1760,7 @@ public function performScheduledSync(int $minutesBack = 10): array * Uses full sync (minutesBack = 0) for manual triggers by default. * * @param int $minutesBack Number of minutes to look back for changes (default: 0 for full sync) - * + * * @return array Synchronization results formatted for API response */ public function performManualSync(int $minutesBack = 0): array @@ -890,10 +1771,13 @@ public function performManualSync(int $minutesBack = 0): array ]); try { - // Perform the core synchronization with time-based filtering - $syncResults = $this->performFullSync($minutesBack); + $syncResults = $this->performOrganizationsSync(); - // Record the sync time + $syncResults = array_merge($this->performContactSync(), $syncResults); + + $this->performUserSync(); + + // Record the sync time for consistency with scheduled sync $this->recordSyncTime(); $this->logger->info('OrganizationSyncService: Manual organization synchronization completed via API', [ @@ -919,7 +1803,8 @@ public function performManualSync(int $minutesBack = 0): array return [ 'success' => false, - 'message' => 'Synchronization failed: ' . $e->getMessage() + 'message' => 'Synchronization failed: ' . $e->getMessage(), + 'trace' => $e->getTraceAsString() ]; } } @@ -942,7 +1827,7 @@ public function getSyncStatusWithErrorHandling(int $minutesBack = 10): array 'minutesBack' => $minutesBack, 'exception' => $e->getMessage() ]); - + return [ 'configured' => false, 'syncMode' => $minutesBack === 0 ? 'full' : 'incremental', @@ -951,4 +1836,127 @@ public function getSyncStatusWithErrorHandling(int $minutesBack = 10): array ]; } } -} \ No newline at end of file + + /** + * Updates the organisatie object's @self metadata to set owner to the organisation entity UUID + * + * @param object $organisatieObject The organisatie object to update + * @param object $organisationEntity The organisation entity + * @param string $register The register ID + * @param string $organizationSchema The organization schema ID + * @return void + */ + private function updateOrganisatieObjectOwner(object $organisatieObject, object $organisationEntity, string $register, string $organizationSchema): void + { + try { + $organisatieId = $organisatieObject->getUuid(); + $organisationEntityUuid = $organisationEntity->getUuid(); + + $this->logger->info('OrganizationSyncService: Updating organisatie object owner', [ + 'organisatieId' => $organisatieId, + 'organisationEntityUuid' => $organisationEntityUuid, + 'register' => $register, + 'schema' => $organizationSchema + ]); + + // Get the current object data + $currentObject = $organisatieObject->getObject(); + + // Get current @self metadata or create new + $selfMetadata = $currentObject['@self'] ?? []; + + // Update the owner field to the organisation entity UUID + $selfMetadata['owner'] = $organisationEntityUuid; + + // Update the object with the new @self metadata + $currentObject['@self'] = $selfMetadata; + $organisatieObject->setObject($currentObject); + + // Save the updated object using ObjectService + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + $objectService->saveObject( + object: $organisatieObject, + register: $register, + schema: $organizationSchema, + rbac: false, + multi: false + ); + + $this->logger->info('OrganizationSyncService: Successfully updated organisatie object owner', [ + 'organisatieId' => $organisatieId, + 'organisationEntityUuid' => $organisationEntityUuid, + 'ownerSet' => $selfMetadata['owner'] + ]); + + } catch (\Exception $e) { + $this->logger->error('OrganizationSyncService: Failed to update organisatie object owner', [ + 'organisatieId' => $organisatieObject->getUuid(), + 'organisationEntityUuid' => $organisationEntity->getUuid(), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } + } + + /** + * Updates the contactpersoon object's @self metadata to set owner to the user UID + * + * @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 + * @return void + */ + private function updateContactpersoonObjectOwner(object $contactObject, string $userUID, string $register, string $contactSchema): void + { + try { + $contactId = $contactObject->getUuid(); + + $this->logger->info('OrganizationSyncService: Updating contactpersoon object owner', [ + 'contactId' => $contactId, + 'userUID' => $userUID, + 'register' => $register, + 'schema' => $contactSchema + ]); + + // 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; + + // Update the object with the new @self metadata + $currentObject['@self'] = $selfMetadata; + $contactObject->setObject($currentObject); + + // Save the updated object using ObjectService + $objectService = \OC::$server->get('OCA\OpenRegister\Service\ObjectService'); + $objectService->saveObject( + object: $contactObject, + register: $register, + schema: $contactSchema, + rbac: false, + multi: false + ); + + $this->logger->info('OrganizationSyncService: Successfully updated contactpersoon object owner', [ + 'contactId' => $contactId, + 'userUID' => $userUID, + 'ownerSet' => $selfMetadata['owner'] + ]); + + } catch (\Exception $e) { + $this->logger->error('OrganizationSyncService: Failed to update contactpersoon object owner', [ + 'contactId' => $contactObject->getUuid(), + 'userUID' => $userUID, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + } + } +} diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index c2eb8bf3..37c776fd 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -49,6 +49,20 @@ class SettingsService */ private string $_appName; + /** + * Cache for schema IDs by object type to avoid repeated database queries + * + * @var array + */ + private array $schemaIdCache = []; + + /** + * Cache for register IDs by object type to avoid repeated database queries + * + * @var array + */ + private array $registerIdCache = []; + /** * The unique identifier for the OpenRegister application * @@ -156,27 +170,27 @@ public function getSettings(): array { // Initialize the data array $data = []; - + // Define the register-specific configuration $data['registerTypes'] = [ 'amef' => [ 'name' => 'AMEF', 'description' => 'AMEF register for architectural elements', - 'objectTypes' => ['organization', 'element', 'relationship', 'view', 'model', 'property'] // Complete AMEF object types + 'objectTypes' => ['organization', 'element', 'relation', 'view', 'model', 'property', 'property-definition'] // Complete AMEF object types ], 'voorzieningen' => [ - 'name' => 'Voorzieningen', + 'name' => 'Voorzieningen', 'description' => 'Voorzieningen register for software catalog services', - 'objectTypes' => ['organisatie', 'contactpersoon'] // Voorzieningen uses organisatie and contactpersoon schemas + 'objectTypes' => ['sector', 'product', 'dienst', 'kwetsbaarheid', 'contactpersoon', 'organisatie', 'gebruik', 'contract', 'koppeling', 'beoordeeling', 'module', 'compliancy', 'moduleVersie'] // All voorzieningen schemas ] ]; - + // Deprecated: For backward compatibility only - use registerTypes instead $data['objectTypes'] = [ 'organization', 'contact', ]; - + $data['openRegisters'] = false; $data['availableRegisters'] = []; @@ -185,20 +199,46 @@ public function getSettings(): array $openRegisters = $this->getObjectService(); if ($openRegisters !== null) { $data['openRegisters'] = true; - $rawRegisters = $openRegisters->getRegisters(); - // Filter schemas to remove properties field for cleaner response - $data['availableRegisters'] = array_map(function($register) { - if (isset($register['schemas']) && is_array($register['schemas'])) { - $register['schemas'] = array_map(function($schema) { - // Keep only essential schema fields, remove properties - return array_filter($schema, function($key) { - return !in_array($key, ['properties']); - }, ARRAY_FILTER_USE_KEY); - }, $register['schemas']); - } - return $register; - }, $rawRegisters); + // Add additional error handling for OpenRegister internal errors + try { + $rawRegisters = $openRegisters->getRegisters(); + + // Filter schemas to remove properties field for cleaner response + $data['availableRegisters'] = array_map(function($register) { + if (isset($register['schemas']) && is_array($register['schemas'])) { + $register['schemas'] = array_map(function($schema) { + // Keep only essential schema fields, remove properties + return array_filter($schema, function($key) { + return !in_array($key, ['properties']); + }, ARRAY_FILTER_USE_KEY); + }, $register['schemas']); + } + return $register; + }, $rawRegisters); + } catch (\TypeError $e) { + // Handle OpenRegister internal errors (e.g. RegisterMapper parameter issues) + $this->logger->warning( + 'OpenRegister internal error - using empty registers list', + [ + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + $data['availableRegisters'] = []; + } catch (\Exception $e) { + // Handle any other OpenRegister errors + $this->logger->warning( + 'OpenRegister getRegisters() failed - using empty registers list', + [ + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ] + ); + $data['availableRegisters'] = []; + } } } catch (\RuntimeException $e) { // Service not available, continue with default values @@ -220,14 +260,14 @@ public function getSettings(): array $defaults["{$registerType}_{$objectType}_register"] = ''; } } - + // Also maintain backward compatibility for the old structure foreach ($data['objectTypes'] as $type) { $defaults["{$type}_source"] = 'openregister'; $defaults["{$type}_schema"] = ''; $defaults["{$type}_register"] = ''; } - + // Note: Old individual config keys are no longer used // They are maintained only for backward compatibility during migration @@ -237,6 +277,9 @@ public function getSettings(): array $data['configuration'][$key] = $this->config->getValueString($this->_appName, $key, $defaultValue); } + // Add catalog location + $data['catalogLocation'] = $this->getCatalogLocation(); + return $data; } catch (\Exception $e) { throw new \RuntimeException('Failed to retrieve settings: ' . $e->getMessage()); @@ -300,9 +343,9 @@ public function autoConfigure(bool $force = false): array /** * Auto-configures settings specifically after importing the softwarecatalogus_register.json - * + * * This method looks for the voorzieningen register and automatically configures - * the organisatie and contactpersoon schemas, and creates required user groups. + * ALL schemas using the new consolidated configuration format, and creates required user groups. * * @return array The updated configuration * @@ -318,119 +361,53 @@ public function autoConfigureAfterImport(): array return []; } - $objectService = $this->getObjectService(); - $registers = $objectService->getRegisters(); - - if (empty($registers)) { - $this->logger->info('No registers available for auto-configuration after import'); - return []; - } + $this->logger->info('Starting comprehensive auto-configuration after import'); - $configuration = []; - // Step 1: Create required user groups $this->logger->info('Creating required user groups'); $this->createRequiredUserGroups(); $this->logger->info('User groups created successfully'); - - // Look for the voorzieningen register - $voorzieningenRegister = null; - foreach ($registers as $register) { - $registerTitle = strtolower($register['title'] ?? ''); - $registerSlug = strtolower($register['slug'] ?? ''); - - if (stripos($registerTitle, 'voorzieningen') !== false || - stripos($registerSlug, 'voorzieningen') !== false || - $registerTitle === 'voorzieningen' || - $registerSlug === 'voorzieningen') { - $voorzieningenRegister = $register; - break; - } - } - if ($voorzieningenRegister === null) { - $this->logger->info('No voorzieningen register found for auto-configuration after import'); + // Step 2: Configure Voorzieningen using the consolidated method + $this->logger->info('Running voorzieningen auto-configuration'); + $voorzieningenResult = $this->configureVoorzieningen(); + + if (!$voorzieningenResult['success']) { + $this->logger->warning('Voorzieningen auto-configuration failed', [ + 'message' => $voorzieningenResult['message'] ?? 'Unknown error' + ]); return []; } - $this->logger->info('Found voorzieningen register for auto-configuration', [ - 'register_id' => $voorzieningenRegister['id'], - 'register_title' => $voorzieningenRegister['title'], - 'schemas_count' => count($voorzieningenRegister['schemas'] ?? []) + $this->logger->info('Voorzieningen auto-configuration completed successfully', [ + 'configured' => $voorzieningenResult['configured'] ?? [] ]); - // Configure schemas within the voorzieningen register - if (!empty($voorzieningenRegister['schemas'])) { - foreach ($voorzieningenRegister['schemas'] as $schema) { - $schemaTitle = strtolower($schema['title'] ?? ''); - $schemaSlug = strtolower($schema['slug'] ?? ''); - - // Look for organisatie schema - if (stripos($schemaTitle, 'organisatie') !== false || - stripos($schemaSlug, 'organisatie') !== false || - $schemaTitle === 'organisatie' || - $schemaSlug === 'organisatie') { - - // Set voorzieningen_organisatie configuration - $configuration['voorzieningen_organisatie_source'] = 'openregister'; - $configuration['voorzieningen_organisatie_register'] = (string) $voorzieningenRegister['id']; - $configuration['voorzieningen_organisatie_schema'] = (string) $schema['id']; - - // Set sync-compatible configuration (OrganizationSyncService expects this key) - $configuration['voorzieningen_register'] = (string) $voorzieningenRegister['id']; - - // Also set backward compatibility organization configuration - $configuration['organization_source'] = 'openregister'; - $configuration['organization_register'] = (string) $voorzieningenRegister['id']; - $configuration['organization_schema'] = (string) $schema['id']; - - $this->logger->info('Configured organisatie schema', [ - 'schema_id' => $schema['id'], - 'schema_title' => $schema['title'] - ]); - } - // Look for contactpersoon schema - else if (stripos($schemaTitle, 'contactpersoon') !== false || - stripos($schemaSlug, 'contactpersoon') !== false || - $schemaTitle === 'contactpersoon' || - $schemaSlug === 'contactpersoon') { - - // Set voorzieningen_contactpersoon configuration - $configuration['voorzieningen_contactpersoon_source'] = 'openregister'; - $configuration['voorzieningen_contactpersoon_register'] = (string) $voorzieningenRegister['id']; - $configuration['voorzieningen_contactpersoon_schema'] = (string) $schema['id']; - - // Set sync-compatible configuration (OrganizationSyncService expects this key) - $configuration['voorzieningen_register'] = (string) $voorzieningenRegister['id']; - - // Also set backward compatibility contact configuration - $configuration['contact_source'] = 'openregister'; - $configuration['contact_register'] = (string) $voorzieningenRegister['id']; - $configuration['contact_schema'] = (string) $schema['id']; - - $this->logger->info('Configured contactpersoon schema', [ - 'schema_id' => $schema['id'], - 'schema_title' => $schema['title'] - ]); - } - } - } - - if (empty($configuration)) { - $this->logger->info('No matching schemas found in voorzieningen register for auto-configuration'); + // Step 3: Configure AMEF using the consolidated method + $this->logger->info('Running AMEF auto-configuration'); + $amefResult = $this->configureAmef(); + + if (!$amefResult['success']) { + $this->logger->info('AMEF auto-configuration not completed', [ + 'message' => $amefResult['message'] ?? 'No AMEF register found' + ]); } else { - $this->logger->info('Auto-configuration after import completed successfully', [ - 'configuration_keys' => array_keys($configuration), - 'register_used' => $voorzieningenRegister['title'] + $this->logger->info('AMEF auto-configuration completed successfully', [ + 'configured' => $amefResult['configured'] ?? [] ]); } // Mark auto-configuration as completed $this->config->setValueString($this->_appName, 'auto_config_completed', 'true'); - $this->logger->info('Auto-configuration marked as completed'); + $this->logger->info('Comprehensive auto-configuration marked as completed'); + + // Return the consolidated configuration result + return [ + 'voorzieningen' => $voorzieningenResult, + 'amef' => $amefResult, + 'user_groups_created' => true + ]; - return $configuration; - } catch (\Exception $e) { throw new \RuntimeException('Failed to auto-configure after import: ' . $e->getMessage()); } @@ -445,151 +422,116 @@ public function autoConfigureAfterImport(): array */ public function getSchemaIdForObjectType(string $objectType): ?int { + // Check cache first for performance optimization + if (array_key_exists($objectType, $this->schemaIdCache)) { + $cachedValue = $this->schemaIdCache[$objectType]; + $this->logger->debug("SettingsService: Schema ID retrieved from cache", [ + 'objectType' => $objectType, + 'cachedValue' => $cachedValue, + 'fromCache' => true + ]); + return $cachedValue; + } + $startTime = microtime(true); - - $this->logger->debug("SettingsService: Starting schema ID lookup", [ + $result = null; + + $this->logger->debug("SettingsService: Starting schema ID lookup (cache miss)", [ 'objectType' => $objectType, 'timestamp' => date('Y-m-d H:i:s') ]); - + // First try register-specific configuration - // Check for AMEF register specific schemas - if ($objectType === 'organization') { - $this->logger->debug("SettingsService: Checking AMEF organization schema", [ - 'objectType' => $objectType - ]); - + // Check for AMEF register specific schemas from JSON config + $amefConfig = $this->config->getValueString($this->_appName, 'amef_config', '{}'); + if (!empty($amefConfig) && $amefConfig !== '{}') { + $decodedAmefConfig = json_decode($amefConfig, true); + if (is_array($decodedAmefConfig)) { + // Map object types to their corresponding AMEF config keys + $amefKeyMap = [ + 'model' => 'model_schema', + 'element' => 'element_schema', + 'relationship' => 'relation_schema', // Note: relation vs relationship + 'view' => 'view_schema', + 'property_definition' => 'property_definition_schema', // Property definitions are root-level AMEF objects + 'organization' => 'organization_schema' + // NOTE: 'property' mapping removed - properties are never root-level AMEF objects, only nested within other elements + ]; + + $amefKey = $amefKeyMap[$objectType] ?? null; + + if ($amefKey && isset($decodedAmefConfig[$amefKey])) { + $schemaId = $decodedAmefConfig[$amefKey]; + if (!empty($schemaId)) { + $result = (int) $schemaId; + $this->logger->debug("SettingsService: Found schema ID in AMEF JSON config", [ + 'objectType' => $objectType, + 'amefKey' => $amefKey, + 'schemaId' => $result + ]); + } + } + } + } + + // Check for AMEF register specific schemas (legacy individual keys) + if ($result === null && $objectType === 'organization') { $schemaId = $this->config->getValueString($this->_appName, 'amef_organization_schema', ''); - $this->logger->debug("SettingsService: AMEF organization schema result", [ - 'objectType' => $objectType, - 'configKey' => 'amef_organization_schema', - 'rawValue' => $schemaId, - 'isEmpty' => empty($schemaId) - ]); - if (!empty($schemaId)) { $result = (int) $schemaId; - $this->logger->info("SettingsService: Found AMEF organization schema", [ - 'objectType' => $objectType, - 'schemaId' => $result, - 'lookupTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms' - ]); - return $result; + } else { + // Also check voorzieningen register for organization/organisatie + $schemaId = $this->config->getValueString($this->_appName, 'voorzieningen_organisatie_schema', ''); + if (!empty($schemaId)) { + $result = (int) $schemaId; + } } - - // Also check voorzieningen register for organization/organisatie - $this->logger->debug("SettingsService: Checking voorzieningen organisatie schema for organization", [ - 'objectType' => $objectType - ]); - + } + + if ($objectType === 'organisatie' && $result === null) { $schemaId = $this->config->getValueString($this->_appName, 'voorzieningen_organisatie_schema', ''); - - $this->logger->debug("SettingsService: Voorzieningen organisatie schema result", [ - 'objectType' => $objectType, - 'configKey' => 'voorzieningen_organisatie_schema', - 'rawValue' => $schemaId, - 'isEmpty' => empty($schemaId) - ]); - if (!empty($schemaId)) { $result = (int) $schemaId; - $this->logger->info("SettingsService: Found voorzieningen organisatie schema for organization", [ - 'objectType' => $objectType, - 'schemaId' => $result, - 'lookupTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms' - ]); - return $result; } } - - if ($objectType === 'organisatie') { - $this->logger->debug("SettingsService: Checking voorzieningen organisatie schema", [ - 'objectType' => $objectType - ]); - - $schemaId = $this->config->getValueString($this->_appName, 'voorzieningen_organisatie_schema', ''); - - $this->logger->debug("SettingsService: Voorzieningen organisatie schema result", [ - 'objectType' => $objectType, - 'configKey' => 'voorzieningen_organisatie_schema', - 'rawValue' => $schemaId, - 'isEmpty' => empty($schemaId) - ]); - + + if ($objectType === 'contactpersoon' && $result === null) { + $schemaId = $this->config->getValueString($this->_appName, 'voorzieningen_contactpersoon_schema', ''); if (!empty($schemaId)) { $result = (int) $schemaId; - $this->logger->info("SettingsService: Found voorzieningen organisatie schema", [ - 'objectType' => $objectType, - 'schemaId' => $result, - 'lookupTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms' - ]); - return $result; } } - - if ($objectType === 'contactpersoon') { - $this->logger->debug("SettingsService: Checking voorzieningen contactpersoon schema", [ - 'objectType' => $objectType - ]); - - $schemaId = $this->config->getValueString($this->_appName, 'voorzieningen_contactpersoon_schema', ''); - - $this->logger->debug("SettingsService: Voorzieningen contactpersoon schema result", [ - 'objectType' => $objectType, - 'configKey' => 'voorzieningen_contactpersoon_schema', - 'rawValue' => $schemaId, - 'isEmpty' => empty($schemaId) - ]); - + + // Fall back to generic configuration for backward compatibility + if ($result === null) { + $schemaId = $this->config->getValueString($this->_appName, "{$objectType}_schema", ''); if (!empty($schemaId)) { $result = (int) $schemaId; - $this->logger->info("SettingsService: Found voorzieningen contactpersoon schema", [ - 'objectType' => $objectType, - 'schemaId' => $result, - 'lookupTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms' - ]); - return $result; } } + + // Cache the result (even if null) to avoid repeated lookups + $this->schemaIdCache[$objectType] = $result; + + $lookupTime = round((microtime(true) - $startTime) * 1000, 2); - // Fall back to generic configuration for backward compatibility - $this->logger->debug("SettingsService: Checking generic configuration", [ - 'objectType' => $objectType, - 'configKey' => "{$objectType}_schema" - ]); - - $schemaId = $this->config->getValueString($this->_appName, "{$objectType}_schema", ''); - - $this->logger->debug("SettingsService: Generic configuration result", [ - 'objectType' => $objectType, - 'configKey' => "{$objectType}_schema", - 'rawValue' => $schemaId, - 'isEmpty' => empty($schemaId) - ]); - - if ($schemaId) { - $result = (int) $schemaId; - $this->logger->info("SettingsService: Found generic schema configuration", [ + if ($result !== null) { + $this->logger->info("SettingsService: Found schema ID and cached result", [ 'objectType' => $objectType, 'schemaId' => $result, - 'lookupTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms' + 'lookupTime' => $lookupTime . 'ms', + 'fromCache' => false + ]); + } else { + $this->logger->debug("SettingsService: No schema ID found, cached null result", [ + 'objectType' => $objectType, + 'lookupTime' => $lookupTime . 'ms', + 'fromCache' => false ]); - return $result; } - - $this->logger->warning("SettingsService: No schema ID found for object type", [ - 'objectType' => $objectType, - 'checkedConfigurations' => [ - 'amef_organization_schema' => ($objectType === 'organization'), - 'voorzieningen_organisatie_schema' => ($objectType === 'organization' || $objectType === 'organisatie'), - 'voorzieningen_contactpersoon_schema' => ($objectType === 'contactpersoon'), - "{$objectType}_schema" => true - ], - 'lookupTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms' - ]); - - return null; + + return $result; } /** @@ -601,8 +543,52 @@ public function getSchemaIdForObjectType(string $objectType): ?int */ public function getRegisterIdForObjectType(string $objectType): ?int { + // Check cache first for performance optimization + if (array_key_exists($objectType, $this->registerIdCache)) { + $cachedValue = $this->registerIdCache[$objectType]; + $this->logger->debug("SettingsService: Register ID retrieved from cache", [ + 'objectType' => $objectType, + 'cachedValue' => $cachedValue, + 'fromCache' => true + ]); + return $cachedValue; + } + + // Perform the actual lookup $registerId = $this->config->getValueString($this->_appName, "{$objectType}_register", ''); - return $registerId ? (int) $registerId : null; + $result = $registerId ? (int) $registerId : null; + + // Cache the result (even if null) to avoid repeated lookups + $this->registerIdCache[$objectType] = $result; + + $this->logger->debug("SettingsService: Register ID looked up and cached", [ + 'objectType' => $objectType, + 'result' => $result, + 'fromCache' => false + ]); + + return $result; + } + + /** + * Clear cached schema and register IDs + * + * This method should be called when configuration changes to ensure + * cached values don't become stale. + * + * @return void + */ + public function clearConfigurationCache(): void + { + $this->logger->debug("SettingsService: Clearing configuration cache", [ + 'cached_schema_ids' => count($this->schemaIdCache), + 'cached_register_ids' => count($this->registerIdCache) + ]); + + $this->schemaIdCache = []; + $this->registerIdCache = []; + + $this->logger->info("SettingsService: Configuration cache cleared"); } /** @@ -613,24 +599,24 @@ public function getRegisterIdForObjectType(string $objectType): ?int public function getVoorzieningenRegisterId(): ?int { $startTime = microtime(true); - + $this->logger->debug("SettingsService: Starting voorzieningen register ID lookup", [ 'timestamp' => date('Y-m-d H:i:s') ]); - + // Try voorzieningen-specific configuration first $this->logger->debug("SettingsService: Checking voorzieningen organisatie register", [ 'configKey' => 'voorzieningen_organisatie_register' ]); - + $registerId = $this->config->getValueString($this->_appName, 'voorzieningen_organisatie_register', ''); - + $this->logger->debug("SettingsService: Voorzieningen organisatie register result", [ 'configKey' => 'voorzieningen_organisatie_register', 'rawValue' => $registerId, 'isEmpty' => empty($registerId) ]); - + if (!empty($registerId)) { $result = (int) $registerId; $this->logger->info("SettingsService: Found voorzieningen organisatie register", [ @@ -639,20 +625,20 @@ public function getVoorzieningenRegisterId(): ?int ]); return $result; } - + // Also try contactpersoon as fallback $this->logger->debug("SettingsService: Checking voorzieningen contactpersoon register", [ 'configKey' => 'voorzieningen_contactpersoon_register' ]); - + $registerId = $this->config->getValueString($this->_appName, 'voorzieningen_contactpersoon_register', ''); - + $this->logger->debug("SettingsService: Voorzieningen contactpersoon register result", [ 'configKey' => 'voorzieningen_contactpersoon_register', 'rawValue' => $registerId, 'isEmpty' => empty($registerId) ]); - + if (!empty($registerId)) { $result = (int) $registerId; $this->logger->info("SettingsService: Found voorzieningen contactpersoon register", [ @@ -661,14 +647,14 @@ public function getVoorzieningenRegisterId(): ?int ]); return $result; } - + // Fall back to organization register for backward compatibility $this->logger->debug("SettingsService: Checking organization register for backward compatibility", [ 'configKey' => 'organization_register' ]); - + $result = $this->getRegisterIdForObjectType('organization'); - + if ($result !== null) { $this->logger->info("SettingsService: Found organization register for backward compatibility", [ 'registerId' => $result, @@ -676,7 +662,7 @@ public function getVoorzieningenRegisterId(): ?int ]); return $result; } - + $this->logger->warning("SettingsService: No register ID found for voorzieningen", [ 'checkedConfigurations' => [ 'voorzieningen_organisatie_register' => true, @@ -685,7 +671,7 @@ public function getVoorzieningenRegisterId(): ?int ], 'lookupTime' => round((microtime(true) - $startTime) * 1000, 2) . 'ms' ]); - + return null; } @@ -697,14 +683,14 @@ public function getVoorzieningenRegisterId(): ?int public function isFullyConfigured(): bool { $objectTypes = ['organization', 'contact']; - + foreach ($objectTypes as $type) { $schemaId = $this->getSchemaIdForObjectType($type); if (!$schemaId) { return false; } } - + return true; } @@ -717,18 +703,18 @@ public function getConfigurationStatus(): array { $objectTypes = ['organization', 'contact']; $status = []; - + foreach ($objectTypes as $type) { $schemaId = $this->getSchemaIdForObjectType($type); $registerId = $this->getRegisterIdForObjectType($type); - + $status[$type] = [ 'configured' => !empty($schemaId) && !empty($registerId), 'schemaId' => $schemaId, 'registerId' => $registerId, ]; } - + return $status; } @@ -761,7 +747,7 @@ public function initialize(?string $minOpenRegisterVersion = self::MIN_OPENREGIS try { // Check if OpenRegister is installed and enabled $checkStart = microtime(true); - + if (!$this->isOpenRegisterInstalled($minOpenRegisterVersion)) { $error = 'OpenRegister is not installed or does not meet minimum version requirements'; $results['errors'][] = $error; @@ -778,7 +764,7 @@ public function initialize(?string $minOpenRegisterVersion = self::MIN_OPENREGIS $results['openRegister'] = true; $results['timing']['openregister_check'] = round((microtime(true) - $checkStart) * 1000, 2) . 'ms'; - + $this->logger->info('SettingsService: OpenRegister is available'); // Load settings from file if needed (do this first) @@ -809,7 +795,7 @@ public function initialize(?string $minOpenRegisterVersion = self::MIN_OPENREGIS $autoConfigStart = microtime(true); if (!$this->isFullyConfigured()) { $this->logger->info('SettingsService: App not fully configured, attempting auto-configuration'); - + try { // First try the post-import auto-configuration (more specific) $configuration = $this->autoConfigureAfterImport(); @@ -846,7 +832,7 @@ public function initialize(?string $minOpenRegisterVersion = self::MIN_OPENREGIS // Final configuration status check $results['fullyConfigured'] = $this->isFullyConfigured(); - + if (!$results['fullyConfigured']) { $warning = 'App is not fully configured after initialization. Manual configuration may be required.'; $results['warnings'][] = $warning; @@ -856,11 +842,11 @@ public function initialize(?string $minOpenRegisterVersion = self::MIN_OPENREGIS } $results['timing']['total'] = round((microtime(true) - $startTime) * 1000, 2) . 'ms'; - + $this->logger->info('SettingsService: Initialization completed', [ 'results' => [ 'openRegister' => $results['openRegister'], - 'autoConfigured' => $results['autoConfigured'], + 'autoConfigured' => $results['autoConfigured'], 'fullyConfigured' => $results['fullyConfigured'], 'settingsLoaded' => $results['settingsLoaded'], 'errors' => count($results['errors']), @@ -893,24 +879,32 @@ public function initialize(?string $minOpenRegisterVersion = self::MIN_OPENREGIS public function loadSettings(bool $force = false): array { $results = []; - + try { // Load settings from merged softwarecatalogus_register.json $softwareCatalogPath = __DIR__ . '/../Settings/softwarecatalogus_register.json'; if (file_exists($softwareCatalogPath)) { $softwareCatalogContent = file_get_contents($softwareCatalogPath); $softwareCatalogSettings = json_decode($softwareCatalogContent, true); - + if (json_last_error() === JSON_ERROR_NONE) { $results['softwarecatalog'] = $softwareCatalogSettings; - + // Import via configuration service if available with version checking try { $configurationService = $this->getConfigurationService(); - + // Get the current app version dynamically $currentAppVersion = $this->appManager->getAppVersion(\OCA\SoftwareCatalog\AppInfo\Application::APP_ID); + // Log the import attempt for debugging + $this->logger->info('SettingsService: Attempting to import softwarecatalogus_register.json', [ + 'force' => $force, + 'app_id' => \OCA\SoftwareCatalog\AppInfo\Application::APP_ID, + 'current_version' => $currentAppVersion, + 'data_size' => strlen(json_encode($softwareCatalogSettings)) + ]); + $importResult = $configurationService->importFromJson( data: $softwareCatalogSettings, owner: null, @@ -919,13 +913,25 @@ public function loadSettings(bool $force = false): array force: $force ); + $this->logger->info('SettingsService: Import completed successfully', [ + 'import_result' => $importResult + ]); + $results['softwarecatalog_imported'] = true; $results['import_result'] = $importResult; } catch (\Exception $e) { $results['softwarecatalog_import_error'] = $e->getMessage(); $this->logger->error('Failed to import softwarecatalog settings: ' . $e->getMessage(), [ - 'exception' => $e + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + 'force_flag' => $force, + 'app_id' => \OCA\SoftwareCatalog\AppInfo\Application::APP_ID ]); + + // In force mode, we want to surface import errors more prominently + if ($force) { + throw new \RuntimeException('Force import failed: ' . $e->getMessage(), 0, $e); + } } } } @@ -935,7 +941,7 @@ public function loadSettings(bool $force = false): array } return $results; - + } catch (\Exception $e) { throw new \RuntimeException('Failed to load settings: ' . $e->getMessage()); } @@ -949,13 +955,12 @@ public function loadSettings(bool $force = false): array public function getGenericUserGroups(): array { $groupsJson = $this->config->getValueString($this->_appName, 'generic_user_groups', ''); - + if (empty($groupsJson)) { // Return default groups if no configuration exists return [ 'beheerder', 'inkoper', - 'ambtenaar', 'software-catalog-users' ]; } @@ -968,14 +973,14 @@ public function getGenericUserGroups(): array * Sets the list of generic user groups in configuration * * @param array $groups Array of generic user groups - * + * * @return void */ public function setGenericUserGroups(array $groups): void { $groupsJson = json_encode($groups, JSON_THROW_ON_ERROR); $this->config->setValueString($this->_appName, 'generic_user_groups', $groupsJson); - + $this->logger->info( 'Updated generic user groups configuration', [ @@ -992,7 +997,7 @@ public function setGenericUserGroups(array $groups): void public function getOrganizationAdminGroups(): array { $groupsJson = $this->config->getValueString($this->_appName, 'organization_admin_groups', ''); - + if (empty($groupsJson)) { // Return default groups if no configuration exists return [ @@ -1008,14 +1013,14 @@ public function getOrganizationAdminGroups(): array * Sets the list of organization admin groups in configuration * * @param array $groups Array of organization admin groups - * + * * @return void */ public function setOrganizationAdminGroups(array $groups): void { $groupsJson = json_encode($groups, JSON_THROW_ON_ERROR); $this->config->setValueString($this->_appName, 'organization_admin_groups', $groupsJson); - + $this->logger->info( 'Updated organization admin groups configuration', [ @@ -1032,7 +1037,7 @@ public function setOrganizationAdminGroups(array $groups): void public function getSuperUserGroups(): array { $groupsJson = $this->config->getValueString($this->_appName, 'super_user_groups', ''); - + if (empty($groupsJson)) { // Return default groups if no configuration exists return [ @@ -1049,14 +1054,14 @@ public function getSuperUserGroups(): array * Sets the list of super user groups in configuration * * @param array $groups Array of super user groups - * + * * @return void */ public function setSuperUserGroups(array $groups): void { $groupsJson = json_encode($groups, JSON_THROW_ON_ERROR); $this->config->setValueString($this->_appName, 'super_user_groups', $groupsJson); - + $this->logger->info( 'Updated super user groups configuration', [ @@ -1069,7 +1074,7 @@ public function setSuperUserGroups(array $groups): void * Validates a list of group names * * @param array $groups Array of group names to validate - * + * * @return array Array with validation results */ public function validateGroups(array $groups): array @@ -1079,24 +1084,24 @@ public function validateGroups(array $groups): array 'invalid' => [], 'errors' => [] ]; - + foreach ($groups as $groupName) { if (empty($groupName) || !is_string($groupName)) { $results['invalid'][] = $groupName; $results['errors'][] = 'Group name cannot be empty'; continue; } - + // Check for invalid characters if (preg_match('/[^a-zA-Z0-9._-]/', $groupName)) { $results['invalid'][] = $groupName; $results['errors'][] = "Group name '{$groupName}' contains invalid characters"; continue; } - + $results['valid'][] = $groupName; } - + return $results; } @@ -1111,7 +1116,7 @@ public function createAndConfigureUserGroups(): array { try { $this->logger->info('SettingsService: Starting user group creation and configuration'); - + $result = [ 'success' => true, 'message' => 'User groups configured successfully', @@ -1119,10 +1124,10 @@ public function createAndConfigureUserGroups(): array 'existing' => [], 'total' => 0 ]; - + // Get the group manager $groupManager = \OC::$server->getGroupManager(); - + // Define the required groups (matching role-based system) $requiredGroups = [ // Role-based user groups (exact match with ContactPersoon roles) @@ -1132,28 +1137,28 @@ public function createAndConfigureUserGroups(): array 'functioneel-beheerder' => 'Manages functional aspects of the system', 'vng-raadpleger' => 'Views VNG-related information', 'organisatie-beheerder' => 'Manages organization data and settings', - + // Plural form for organization contacts 'organisaties-beheerder' => 'Organization administrators (plural)', - - // Special groups - 'ambtenaar' => 'Civil servants from Gemeente organizations', + + // Special groups (available for manual assignment) + 'ambtenaar' => 'Civil servants - available for manual assignment (no automatic assignment)', 'software-catalog-users' => 'General software catalog users', - + // Super user groups 'software-catalog-admins' => 'Software catalog system administrators' ]; - + foreach ($requiredGroups as $groupId => $description) { $this->logger->debug("SettingsService: Processing group: {$groupId}"); - + // Check if group already exists if ($groupManager->groupExists($groupId)) { $result['existing'][] = $groupId; $this->logger->debug("SettingsService: Group {$groupId} already exists"); continue; } - + // Create the group $group = $groupManager->createGroup($groupId); if ($group !== false) { @@ -1164,49 +1169,47 @@ public function createAndConfigureUserGroups(): array $result['success'] = false; } } - + $result['total'] = count($requiredGroups); - + // Update the configuration with the correct role-based groups $this->setGenericUserGroups([ 'aanbod-beheerder', - 'gebruik-beheerder', + 'gebruik-beheerder', 'gebruik-raadpleger', 'functioneel-beheerder', - 'vng-raadpleger', 'organisatie-beheerder', - 'ambtenaar', 'software-catalog-users' ]); - + $this->setOrganizationAdminGroups([ 'organisaties-beheerder', 'organisatie-beheerder' ]); - + $this->setSuperUserGroups([ 'admin', // Keep existing admin group 'software-catalog-admins' ]); - + $createdCount = count($result['created']); $existingCount = count($result['existing']); - + if ($createdCount > 0) { $result['message'] = "Created {$createdCount} new groups, {$existingCount} already existed"; } else { $result['message'] = "All {$existingCount} required groups already exist"; } - + $this->logger->info('SettingsService: User group creation and configuration completed', [ 'created_groups' => $result['created'], 'existing_groups' => $result['existing'], 'total_required' => $result['total'], 'success' => $result['success'] ]); - + return $result; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to create and configure user groups', [ 'exception' => $e->getMessage() @@ -1227,7 +1230,7 @@ public function createAndConfigureUserGroups(): array * * This method creates the default user groups needed for proper operation: * - Generic user groups for general access - * - Organization admin groups for managing organizations + * - Organization admin groups for managing organizations * - Super user groups for system administration * * @return void @@ -1238,10 +1241,10 @@ private function createRequiredUserGroups(): void { try { $this->logger->info('Starting creation of required user groups'); - + // Get the group manager $groupManager = \OC::$server->getGroupManager(); - + // Define the required groups (matching role-based system) $requiredGroups = [ // Role-based user groups (exact match with ContactPersoon roles) @@ -1251,31 +1254,31 @@ private function createRequiredUserGroups(): void 'functioneel-beheerder' => 'Manages functional aspects of the system', 'vng-raadpleger' => 'Views VNG-related information', 'organisatie-beheerder' => 'Manages organization data and settings', - + // Plural form for organization contacts 'organisaties-beheerder' => 'Organization administrators (plural)', - - // Special groups - 'ambtenaar' => 'Civil servants from Gemeente organizations', + + // Special groups (available for manual assignment) + 'ambtenaar' => 'Civil servants - available for manual assignment (no automatic assignment)', 'software-catalog-users' => 'General software catalog users', - + // Super user groups 'software-catalog-admins' => 'Software catalog system administrators' ]; - + $createdGroups = []; $existingGroups = []; - + foreach ($requiredGroups as $groupId => $description) { $this->logger->debug("Processing group: {$groupId}"); - + // Check if group already exists if ($groupManager->groupExists($groupId)) { $existingGroups[] = $groupId; $this->logger->debug("Group {$groupId} already exists, skipping"); continue; } - + // Create the group $group = $groupManager->createGroup($groupId); if ($group !== false) { @@ -1285,35 +1288,35 @@ private function createRequiredUserGroups(): void $this->logger->warning("Failed to create user group: {$groupId}"); } } - + // Update the configuration with the correct role-based groups + // Note: 'ambtenaar' is excluded from generic groups since it's no longer automatically assigned $this->setGenericUserGroups([ 'aanbod-beheerder', - 'gebruik-beheerder', + 'gebruik-beheerder', 'gebruik-raadpleger', 'functioneel-beheerder', 'vng-raadpleger', 'organisatie-beheerder', - 'ambtenaar', 'software-catalog-users' ]); - + $this->setOrganizationAdminGroups([ 'organisaties-beheerder', 'organisatie-beheerder' ]); - + $this->setSuperUserGroups([ 'admin', // Keep existing admin group 'software-catalog-admins' ]); - + $this->logger->info('User group creation completed', [ 'created_groups' => $createdGroups, 'existing_groups' => $existingGroups, 'total_required' => count($requiredGroups) ]); - + } catch (\Exception $e) { $this->logger->error('Failed to create required user groups: ' . $e->getMessage(), [ 'exception' => $e @@ -1330,13 +1333,13 @@ private function createRequiredUserGroups(): void public function getAllGroups(): array { $groups = []; - + // Get group manager if possible if ($this->appManager->isInstalled('user_management')) { try { $groupManager = \OC::$server->getGroupManager(); $allGroups = $groupManager->search(''); - + foreach ($allGroups as $group) { $groups[] = [ 'id' => $group->getGID(), @@ -1349,7 +1352,7 @@ public function getAllGroups(): array $this->logger->error('Failed to get all groups: ' . $e->getMessage()); } } - + return $groups; } @@ -1361,7 +1364,7 @@ public function getAllGroups(): array public function getEmailSettings(): array { $this->logger->debug('SoftwareCatalog: Loading email settings from configuration'); - + $settings = [ 'enabled' => $this->config->getValueString($this->_appName, 'email_enabled', 'false') === 'true', 'senderEmail' => $this->config->getValueString($this->_appName, 'sender_email', 'noreply@softwarecatalogus.nl'), @@ -1371,36 +1374,37 @@ public function getEmailSettings(): array 'organizationActivationEnabled' => $this->config->getValueString($this->_appName, 'email_org_activation_enabled', 'true') === 'true', 'userCreationEnabled' => $this->config->getValueString($this->_appName, 'email_user_creation_enabled', 'true') === 'true', 'userPasswordEnabled' => $this->config->getValueString($this->_appName, 'email_user_password_enabled', 'true') === 'true', - + 'userOrganisationEnabled' => $this->config->getValueString($this->_appName, 'email_user_organisation_enabled', 'true') === 'true', + // Symfony Mailer transport configuration 'transportType' => $this->config->getValueString($this->_appName, 'email_transport_type', 'smtp'), - + // SMTP configuration 'smtpHost' => $this->config->getValueString($this->_appName, 'email_smtp_host', 'localhost'), 'smtpPort' => (int) $this->config->getValueString($this->_appName, 'email_smtp_port', '587'), 'smtpEncryption' => $this->config->getValueString($this->_appName, 'email_smtp_encryption', 'tls'), 'smtpUsername' => $this->config->getValueString($this->_appName, 'email_smtp_username', ''), 'smtpPassword' => $this->config->getValueString($this->_appName, 'email_smtp_password', ''), - + // SendGrid configuration 'sendgridApiKey' => $this->config->getValueString($this->_appName, 'email_sendgrid_api_key', ''), - + // Mailgun configuration 'mailgunApiKey' => $this->config->getValueString($this->_appName, 'email_mailgun_api_key', ''), 'mailgunDomain' => $this->config->getValueString($this->_appName, 'email_mailgun_domain', ''), - + // Postmark configuration 'postmarkApiKey' => $this->config->getValueString($this->_appName, 'email_postmark_api_key', ''), - + // Amazon SES configuration 'sesAccessKey' => $this->config->getValueString($this->_appName, 'email_ses_access_key', ''), 'sesSecretKey' => $this->config->getValueString($this->_appName, 'email_ses_secret_key', ''), 'sesRegion' => $this->config->getValueString($this->_appName, 'email_ses_region', 'us-east-1'), - + // Mailjet configuration 'mailjetApiKey' => $this->config->getValueString($this->_appName, 'email_mailjet_api_key', ''), 'mailjetSecretKey' => $this->config->getValueString($this->_appName, 'email_mailjet_secret_key', ''), - + // Templates 'templates' => [ 'organization_registration' => $this->getEmailTemplate('organization_registration'), @@ -1409,7 +1413,7 @@ public function getEmailSettings(): array 'user_password' => $this->getEmailTemplate('user_password'), ] ]; - + $this->logger->info('SoftwareCatalog: Email settings loaded from configuration', [ 'enabled' => $settings['enabled'], 'transport_type' => $settings['transportType'], @@ -1420,7 +1424,7 @@ public function getEmailSettings(): array 'mailjet_secret_key_length' => strlen($settings['mailjetSecretKey']), 'test_receiver_override' => $settings['testReceiverOverride'] ]); - + return $settings; } @@ -1442,48 +1446,48 @@ public function updateEmailSettings(array $emailSettings): array 'organizationActivationEnabled' => 'email_org_activation_enabled', 'userCreationEnabled' => 'email_user_creation_enabled', 'userPasswordEnabled' => 'email_user_password_enabled', - + 'userOrganisationEnabled' => 'email_user_organisation_enabled', + // Symfony Mailer transport configuration 'transportType' => 'email_transport_type', - + // SMTP configuration 'smtpHost' => 'email_smtp_host', 'smtpPort' => 'email_smtp_port', 'smtpEncryption' => 'email_smtp_encryption', 'smtpUsername' => 'email_smtp_username', 'smtpPassword' => 'email_smtp_password', - + // SendGrid configuration 'sendgridApiKey' => 'email_sendgrid_api_key', - + // Mailgun configuration 'mailgunApiKey' => 'email_mailgun_api_key', 'mailgunDomain' => 'email_mailgun_domain', - + // Postmark configuration 'postmarkApiKey' => 'email_postmark_api_key', - + // Amazon SES configuration 'sesAccessKey' => 'email_ses_access_key', 'sesSecretKey' => 'email_ses_secret_key', 'sesRegion' => 'email_ses_region', - + // Mailjet configuration 'mailjetApiKey' => 'email_mailjet_api_key', 'mailjetSecretKey' => 'email_mailjet_secret_key', ]; - $updatedSettings = []; - + foreach ($allowedSettings as $settingKey => $configKey) { if (array_key_exists($settingKey, $emailSettings)) { $value = $emailSettings[$settingKey]; - + // Convert boolean values to strings if (is_bool($value)) { $value = $value ? 'true' : 'false'; } - + $this->config->setValueString($this->_appName, $configKey, (string) $value); $updatedSettings[$settingKey] = $this->config->getValueString($this->_appName, $configKey); } @@ -1510,7 +1514,7 @@ public function getEmailTemplate(string $templateName): string { $configKey = "email_template_{$templateName}"; $defaultTemplate = $this->getDefaultEmailTemplate($templateName); - + return $this->config->getValueString($this->_appName, $configKey, $defaultTemplate); } @@ -1527,14 +1531,14 @@ public function updateEmailTemplate(string $templateName, string $templateConten try { $configKey = "email_template_{$templateName}"; $this->config->setValueString($this->_appName, $configKey, $templateContent); - + $this->logger->info( 'Email template updated successfully', [ 'templateName' => $templateName ] ); - + return true; } catch (\Exception $e) { $this->logger->error( @@ -1666,13 +1670,13 @@ public function getEmailTemplateVariables(string $templateName): array public function getDebugInfo(): array { $debugInfo = []; - + try { // Get current configuration values $debugInfo['configuration'] = []; $configKeys = [ 'amef_organization_source', - 'amef_organization_register', + 'amef_organization_register', 'amef_organization_schema', 'voorzieningen_organisatie_source', 'voorzieningen_organisatie_register', @@ -1688,19 +1692,19 @@ public function getDebugInfo(): array 'contact_register', 'contact_schema' ]; - + foreach ($configKeys as $key) { $value = $this->config->getValueString($this->_appName, $key, ''); $debugInfo['configuration'][$key] = empty($value) ? '' : $value; } - + // Get group configurations $debugInfo['userGroups'] = [ 'generic' => $this->getGenericUserGroups(), 'organizationAdmin' => $this->getOrganizationAdminGroups(), 'superUser' => $this->getSuperUserGroups() ]; - + // Get email settings (without sensitive data) $emailSettings = $this->getEmailSettings(); unset($emailSettings['smtpPassword']); @@ -1710,14 +1714,14 @@ public function getDebugInfo(): array unset($emailSettings['sesSecretKey']); unset($emailSettings['mailjetSecretKey']); $debugInfo['emailSettings'] = $emailSettings; - + // Get OpenRegister status $debugInfo['openRegister'] = [ 'installed' => $this->isOpenRegisterInstalled(), 'enabled' => $this->isOpenRegisterEnabled(), 'availableRegisters' => [] ]; - + if ($debugInfo['openRegister']['installed'] && $debugInfo['openRegister']['enabled']) { try { $objectService = $this->getObjectService(); @@ -1726,11 +1730,11 @@ public function getDebugInfo(): array $debugInfo['openRegister']['error'] = $e->getMessage(); } } - + } catch (\Exception $e) { $debugInfo['error'] = $e->getMessage(); } - + return $debugInfo; } @@ -1739,7 +1743,7 @@ public function getDebugInfo(): array * * @param string $email The email address to send to * @param array $emailSettings The email settings to use - * + * * @return array Result of the test email */ public function sendTestEmail(string $email, array $emailSettings = []): array @@ -1752,17 +1756,17 @@ public function sendTestEmail(string $email, array $emailSettings = []): array 'message' => 'Email address is required' ]; } - + $this->logger->info('SoftwareCatalog: Starting sendTestEmail process', [ 'recipient' => $email, 'has_email_settings' => !empty($emailSettings) ]); - + try { // Ensure vendor autoloader is loaded include_once __DIR__ . '/../../vendor/autoload.php'; $this->logger->debug('SoftwareCatalog: Vendor autoloader loaded'); - + // Use provided settings or fall back to stored settings if (empty($emailSettings)) { $emailSettings = $this->getEmailSettings(); @@ -1770,7 +1774,7 @@ public function sendTestEmail(string $email, array $emailSettings = []): array } else { $this->logger->info('SoftwareCatalog: Using provided email settings'); } - + // Log the email configuration (without sensitive data) $this->logger->info('SoftwareCatalog: Email configuration', [ 'enabled' => $emailSettings['enabled'] ?? false, @@ -1780,7 +1784,7 @@ public function sendTestEmail(string $email, array $emailSettings = []): array 'has_mailjet_api_key' => !empty($emailSettings['mailjetApiKey']), 'has_mailjet_secret_key' => !empty($emailSettings['mailjetSecretKey']), ]); - + // Check if email is enabled if (!($emailSettings['enabled'] ?? false)) { $this->logger->warning('SoftwareCatalog: Email notifications are disabled'); @@ -1789,7 +1793,7 @@ public function sendTestEmail(string $email, array $emailSettings = []): array 'message' => 'Email notifications are disabled' ]; } - + // Use test receiver override if configured $recipient = $emailSettings['testReceiverOverride'] ?? $email; $this->logger->info('SoftwareCatalog: Final recipient determined', [ @@ -1797,27 +1801,27 @@ public function sendTestEmail(string $email, array $emailSettings = []): array 'final_recipient' => $recipient, 'using_override' => !empty($emailSettings['testReceiverOverride']) ]); - + // Create transport based on configuration $this->logger->info('SoftwareCatalog: Creating email transport'); $transport = $this->createEmailTransport($emailSettings); $this->logger->info('SoftwareCatalog: Email transport created successfully'); - + $mailer = new Mailer($transport); $this->logger->info('SoftwareCatalog: Mailer instance created'); - + // Create test email $senderEmail = $emailSettings['senderEmail'] ?? 'noreply@softwarecatalogus.nl'; $senderName = $emailSettings['senderName'] ?? 'Software Catalogus'; $transportType = $emailSettings['transportType'] ?? 'smtp'; - + $this->logger->info('SoftwareCatalog: Creating email message', [ 'sender_email' => $senderEmail, 'sender_name' => $senderName, 'transport_type' => $transportType, 'recipient' => $recipient ]); - + $email = (new Email()) ->from(new Address($senderEmail, $senderName)) ->to($recipient) @@ -1830,23 +1834,23 @@ public function sendTestEmail(string $email, array $emailSettings = []): array

Datum: ' . date('Y-m-d H:i:s') . '

Met vriendelijke groet,
Het Software Catalogus Team

'); - + $this->logger->info('SoftwareCatalog: Email message created, attempting to send'); - + // Send the email $mailer->send($email); - + $this->logger->info('SoftwareCatalog: Email sent successfully via Symfony Mailer', [ 'recipient' => $recipient, 'transport' => $transportType, 'sender' => $senderEmail ]); - + return [ 'success' => true, 'message' => "Test email sent successfully to {$recipient} via {$transportType}" ]; - + } catch (\Exception $e) { $this->logger->error('SoftwareCatalog: Failed to send test email', [ 'recipient' => $email, @@ -1866,7 +1870,7 @@ public function sendTestEmail(string $email, array $emailSettings = []): array * Test email connection without sending an actual email * * @param array $emailSettings The email settings to test - * + * * @return array Result of the connection test */ public function testEmailConnection(array $emailSettings = []): array @@ -1874,12 +1878,12 @@ public function testEmailConnection(array $emailSettings = []): array $this->logger->info('SoftwareCatalog: Starting email connection test', [ 'has_email_settings' => !empty($emailSettings) ]); - + try { // Ensure vendor autoloader is loaded include_once __DIR__ . '/../../vendor/autoload.php'; $this->logger->debug('SoftwareCatalog: Vendor autoloader loaded'); - + // Use provided settings or fall back to stored settings if (empty($emailSettings)) { $emailSettings = $this->getEmailSettings(); @@ -1887,7 +1891,7 @@ public function testEmailConnection(array $emailSettings = []): array } else { $this->logger->info('SoftwareCatalog: Using provided email settings'); } - + // Log the email configuration (without sensitive data) $this->logger->info('SoftwareCatalog: Email configuration for connection test', [ 'enabled' => $emailSettings['enabled'] ?? false, @@ -1896,7 +1900,7 @@ public function testEmailConnection(array $emailSettings = []): array 'sender_name' => $emailSettings['senderName'] ?? 'not set', 'has_credentials' => $this->hasValidCredentials($emailSettings) ]); - + // Check if email is enabled if (!($emailSettings['enabled'] ?? false)) { $this->logger->warning('SoftwareCatalog: Email notifications are disabled'); @@ -1905,41 +1909,41 @@ public function testEmailConnection(array $emailSettings = []): array 'message' => 'Email notifications are disabled' ]; } - + // Validate basic settings $transportType = $emailSettings['transportType'] ?? 'smtp'; $senderEmail = $emailSettings['senderEmail'] ?? ''; - + if (empty($senderEmail)) { return [ 'success' => false, 'message' => 'Sender email address is required' ]; } - + // Create transport based on configuration (this tests the connection) $this->logger->info('SoftwareCatalog: Creating email transport for connection test'); $transport = $this->createEmailTransport($emailSettings); $this->logger->info('SoftwareCatalog: Email transport created successfully'); - + // Test the connection by creating a mailer instance $mailer = new Mailer($transport); $this->logger->info('SoftwareCatalog: Mailer instance created for connection test'); - + // For some transports, we can test the connection more directly $connectionDetails = $this->getConnectionDetails($emailSettings); - + $this->logger->info('SoftwareCatalog: Email connection test completed successfully', [ 'transport' => $transportType, 'sender' => $senderEmail ]); - + return [ 'success' => true, 'message' => "Email connection test successful for {$transportType}", 'details' => $connectionDetails ]; - + } catch (\Exception $e) { $this->logger->error('SoftwareCatalog: Email connection test failed', [ 'exception_class' => get_class($e), @@ -1963,7 +1967,7 @@ public function testEmailConnection(array $emailSettings = []): array private function hasValidCredentials(array $emailSettings): bool { $transportType = $emailSettings['transportType'] ?? 'smtp'; - + switch ($transportType) { case 'smtp': return !empty($emailSettings['smtpHost']) && !empty($emailSettings['smtpPort']); @@ -1991,7 +1995,7 @@ private function hasValidCredentials(array $emailSettings): bool private function getConnectionDetails(array $emailSettings): array { $transportType = $emailSettings['transportType'] ?? 'smtp'; - + switch ($transportType) { case 'smtp': return [ @@ -2034,7 +2038,7 @@ private function getConnectionDetails(array $emailSettings): array return ['type' => $transportType]; } } - + /** * Creates an email transport based on configuration * @@ -2045,11 +2049,11 @@ private function getConnectionDetails(array $emailSettings): array private function createEmailTransport(array $emailSettings): \Symfony\Component\Mailer\Transport\TransportInterface { $transportType = $emailSettings['transportType'] ?? 'smtp'; - + $this->logger->info('SoftwareCatalog: Creating transport', [ 'transport_type' => $transportType ]); - + switch ($transportType) { case 'mailjet': $this->logger->info('SoftwareCatalog: Creating Mailjet transport'); @@ -2064,7 +2068,7 @@ private function createEmailTransport(array $emailSettings): \Symfony\Component\ throw new \InvalidArgumentException("Unsupported transport type: {$transportType}"); } } - + /** * Creates a Mailjet transport * @@ -2075,14 +2079,14 @@ private function createMailjetTransport(array $settings): \Symfony\Component\Mai { $apiKey = $settings['mailjetApiKey'] ?? ''; $secretKey = $settings['mailjetSecretKey'] ?? ''; - + $this->logger->info('SoftwareCatalog: Mailjet transport configuration', [ 'has_api_key' => !empty($apiKey), 'api_key_length' => strlen($apiKey), 'has_secret_key' => !empty($secretKey), 'secret_key_length' => strlen($secretKey) ]); - + if (empty($apiKey) || empty($secretKey)) { $this->logger->error('SoftwareCatalog: Mailjet API key and secret key are required', [ 'api_key_empty' => empty($apiKey), @@ -2096,11 +2100,11 @@ private function createMailjetTransport(array $settings): \Symfony\Component\Mai urlencode($apiKey), urlencode($secretKey) ); - + $this->logger->info('SoftwareCatalog: Creating Mailjet transport with DSN', [ 'dsn_pattern' => 'mailjet+api://***:***@default' ]); - + try { $transport = Transport::fromDsn($dsn); $this->logger->info('SoftwareCatalog: Mailjet transport created successfully', [ @@ -2115,7 +2119,7 @@ private function createMailjetTransport(array $settings): \Symfony\Component\Mai throw $e; } } - + /** * Creates an SMTP transport * @@ -2129,7 +2133,7 @@ private function createSmtpTransport(array $settings): \Symfony\Component\Mailer $encryption = $settings['smtpEncryption'] ?? 'tls'; $username = $settings['smtpUsername'] ?? ''; $password = $settings['smtpPassword'] ?? ''; - + $this->logger->info('SoftwareCatalog: SMTP transport configuration', [ 'host' => $host, 'port' => $port, @@ -2137,7 +2141,7 @@ private function createSmtpTransport(array $settings): \Symfony\Component\Mailer 'has_username' => !empty($username), 'has_password' => !empty($password) ]); - + $dsn = sprintf( 'smtp://%s:%s@%s:%d', urlencode($username), @@ -2145,15 +2149,15 @@ private function createSmtpTransport(array $settings): \Symfony\Component\Mailer $host, $port ); - + if ($encryption && $encryption !== 'none') { $dsn .= '?encryption=' . $encryption; } - + $this->logger->info('SoftwareCatalog: Creating SMTP transport with DSN', [ 'dsn_pattern' => sprintf('smtp://***:***@%s:%d%s', $host, $port, $encryption && $encryption !== 'none' ? '?encryption=' . $encryption : '') ]); - + try { $transport = Transport::fromDsn($dsn); $this->logger->info('SoftwareCatalog: SMTP transport created successfully', [ @@ -2182,40 +2186,40 @@ private function shouldLoadSettings(): bool try { // Get the current app version $currentAppVersion = $this->appManager->getAppVersion(\OCA\SoftwareCatalog\AppInfo\Application::APP_ID); - + $this->logger->info('SettingsService: Checking if settings should be loaded', [ 'current_app_version' => $currentAppVersion ]); - + // Get the configuration service to check stored version $configurationService = $this->getConfigurationService(); $storedVersion = $configurationService->getConfiguredAppVersion(\OCA\SoftwareCatalog\AppInfo\Application::APP_ID); - + $this->logger->info('SettingsService: Version comparison details', [ 'current_app_version' => $currentAppVersion, 'stored_config_version' => $storedVersion, 'stored_version_is_null' => $storedVersion === null ]); - + // If no stored version exists, we need to load settings if ($storedVersion === null) { $this->logger->info('SettingsService: No stored version found, settings should be loaded'); return true; } - + // Compare versions using semantic versioning // Load settings if current version is newer than stored version $shouldLoad = version_compare($currentAppVersion, $storedVersion, '>'); - + $this->logger->info('SettingsService: Version comparison result', [ 'current_version' => $currentAppVersion, 'stored_version' => $storedVersion, 'should_load' => $shouldLoad, 'version_compare_result' => version_compare($currentAppVersion, $storedVersion) ]); - + return $shouldLoad; - + } catch (\Exception $e) { // If we can't determine versions, err on the side of loading settings $this->logger->warning('Failed to check if settings should be loaded: ' . $e->getMessage(), [ @@ -2239,15 +2243,15 @@ public function getVersionInfo(): array try { // Get the current app version $currentAppVersion = $this->appManager->getAppVersion(\OCA\SoftwareCatalog\AppInfo\Application::APP_ID); - + $this->logger->debug('SettingsService: Getting version information', [ 'current_app_version' => $currentAppVersion ]); - + // Get the configuration service to check stored version $configurationService = $this->getConfigurationService(); $storedConfigVersion = null; - + try { $storedConfigVersion = $configurationService->getConfiguredAppVersion(\OCA\SoftwareCatalog\AppInfo\Application::APP_ID); } catch (\Exception $e) { @@ -2256,18 +2260,18 @@ public function getVersionInfo(): array ]); // Continue with null stored version } - + // Determine if versions match - $versionsMatch = $storedConfigVersion !== null && + $versionsMatch = $storedConfigVersion !== null && version_compare($currentAppVersion, $storedConfigVersion, '='); - - $needsUpdate = $storedConfigVersion === null || + + $needsUpdate = $storedConfigVersion === null || version_compare($currentAppVersion, $storedConfigVersion, '>'); - + // Check OpenRegister status $openRegisterInstalled = $this->isOpenRegisterInstalled(); $openRegisterEnabled = $openRegisterInstalled && $this->isOpenRegisterEnabled(); - + $versionInfo = [ 'appName' => 'SoftwareCatalog', 'appVersion' => $currentAppVersion, @@ -2280,9 +2284,9 @@ public function getVersionInfo(): array 'openRegisterInstalled' => $openRegisterInstalled, 'openRegisterEnabled' => $openRegisterEnabled ]; - + $this->logger->info('SettingsService: Version information compiled', $versionInfo); - + return $versionInfo; } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to get version information', [ @@ -2304,13 +2308,13 @@ public function forceUpdate(): array { try { $this->logger->info('SettingsService: Starting force update'); - + // Reset auto-configuration flag $this->config->setValueString($this->_appName, 'auto_config_completed', 'false'); - + // Perform forced import $importResult = $this->manualImport(true); - + if (!$importResult['success']) { return [ 'success' => false, @@ -2318,19 +2322,19 @@ public function forceUpdate(): array 'importResult' => $importResult ]; } - + // Verify configuration after force update $finalVersionInfo = $this->getVersionInfo(); $finalConfigStatus = $this->getConfigurationStatus(); - + $success = $finalVersionInfo['versionsMatch'] || !$finalVersionInfo['needsUpdate']; - + $this->logger->info('SettingsService: Force update completed', [ 'success' => $success, 'final_version_info' => $finalVersionInfo, 'final_config_status' => $finalConfigStatus ]); - + return [ 'success' => $success, 'message' => $success ? 'Force update completed successfully' : 'Force update completed but configuration may need attention', @@ -2338,7 +2342,7 @@ public function forceUpdate(): array 'finalVersionInfo' => $finalVersionInfo, 'finalConfigStatus' => $finalConfigStatus ]; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Force update failed', [ 'exception_message' => $e->getMessage(), @@ -2368,12 +2372,12 @@ public function resetAutoConfiguration(bool $resetConfiguration = false): array $this->logger->info('Resetting auto-configuration', [ 'reset_configuration' => $resetConfiguration ]); - + // Reset the auto-configuration completion flag $this->config->setValueString($this->_appName, 'auto_config_completed', 'false'); - + $resetItems = ['auto_config_completed_flag']; - + if ($resetConfiguration) { // Reset schema and register configurations $configKeysToReset = [ @@ -2390,24 +2394,24 @@ public function resetAutoConfiguration(bool $resetConfiguration = false): array 'contact_register', 'contact_schema' ]; - + foreach ($configKeysToReset as $key) { $this->config->setValueString($this->_appName, $key, ''); } - + $resetItems[] = 'schema_register_configurations'; } - + $this->logger->info('Auto-configuration reset completed', [ 'reset_items' => $resetItems ]); - + return [ 'success' => true, 'message' => 'Auto-configuration reset successfully', 'reset_items' => $resetItems ]; - + } catch (\Exception $e) { $this->logger->error('Failed to reset auto-configuration: ' . $e->getMessage()); return [ @@ -2434,12 +2438,12 @@ public function manualImport(bool $forceImport = false): array $this->logger->info('SettingsService: Starting manual import', [ 'force_import' => $forceImport ]); - + // Get version info first $versionInfo = $this->getVersionInfo(); - + $this->logger->info('SettingsService: Pre-import version info', $versionInfo); - + // Check if import is needed (unless forced) if (!$forceImport && $versionInfo['versionsMatch'] && $versionInfo['isFullyConfigured']) { $this->logger->info('SettingsService: Import not needed - versions match and fully configured'); @@ -2449,7 +2453,7 @@ public function manualImport(bool $forceImport = false): array 'versionInfo' => $versionInfo ]; } - + // If force import is requested or auto-config not completed, reset auto-configuration flag if ($forceImport || !$versionInfo['autoConfigCompleted']) { $this->config->setValueString($this->_appName, 'auto_config_completed', 'false'); @@ -2457,14 +2461,14 @@ public function manualImport(bool $forceImport = false): array 'reason' => $forceImport ? 'force_import' : 'auto_config_not_completed' ]); } - + // Perform the import $this->logger->info('SettingsService: Starting settings import'); $importResult = $this->loadSettings($forceImport); $this->logger->info('SettingsService: Settings import completed', [ 'import_result' => $importResult ]); - + // Auto-configure after successful import $autoConfigResult = null; try { @@ -2486,15 +2490,15 @@ public function manualImport(bool $forceImport = false): array ]); // Don't fail the entire import if auto-configuration fails } - + // Wait a moment for any async operations to complete usleep(100000); // 0.1 seconds - + // Get updated version info - this should now reflect the changes $this->logger->info('SettingsService: Getting updated version info after import'); $updatedVersionInfo = $this->getVersionInfo(); $this->logger->info('SettingsService: Post-import version info', $updatedVersionInfo); - + $message = 'Configuration imported successfully'; if (!empty($autoConfigResult)) { $message .= ' and auto-configured'; @@ -2502,7 +2506,7 @@ public function manualImport(bool $forceImport = false): array if ($forceImport) { $message .= ' (forced import)'; } - + return [ 'success' => true, 'message' => $message, @@ -2511,7 +2515,7 @@ public function manualImport(bool $forceImport = false): array 'versionInfo' => $updatedVersionInfo, 'configurationStatus' => $this->getConfigurationStatus() ]; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Manual import failed', [ 'exception_message' => $e->getMessage(), @@ -2531,7 +2535,7 @@ public function manualImport(bool $forceImport = false): array * * This method orchestrates the complete auto-configuration process: * 1. Configuration file loading - * 2. Voorzieningen register configuration + * 2. Voorzieningen register configuration * 3. AMEF register configuration * 4. User groups configuration * @@ -2543,7 +2547,7 @@ public function performConsolidatedAutoConfiguration(bool $force = false): array $this->logger->info('SettingsService: Starting consolidated auto-configuration', [ 'force' => $force ]); - + $results = [ 'success' => true, 'message' => 'Auto-configuration completed successfully', @@ -2552,42 +2556,42 @@ public function performConsolidatedAutoConfiguration(bool $force = false): array 'timestamp' => time(), 'force' => $force ]; - + // Step 1: Load configuration files $this->logger->info('SettingsService: Step 1 - Loading configuration'); $configResult = $this->loadConfiguration($force); $results['steps']['configurationLoad'] = $configResult; $this->addStepResult($results, $configResult, 'Configuration loading'); - + // Step 2: Configure Voorzieningen (Dutch register system) $this->logger->info('SettingsService: Step 2 - Configuring Voorzieningen'); $voorzieningenResult = $this->configureVoorzieningen(); $results['steps']['voorzieningenConfiguration'] = $voorzieningenResult; $this->addStepResult($results, $voorzieningenResult, 'Voorzieningen configuration'); - + // Step 3: Configure AMEF (ArchiMate/English register system) $this->logger->info('SettingsService: Step 3 - Configuring AMEF'); $amefResult = $this->configureAmef(); $results['steps']['amefConfiguration'] = $amefResult; $this->addStepResult($results, $amefResult, 'AMEF configuration'); - + // Step 4: Configure User Groups $this->logger->info('SettingsService: Step 4 - Configuring User Groups'); $groupsResult = $this->configureGroups(); $results['steps']['groupsConfiguration'] = $groupsResult; $this->addStepResult($results, $groupsResult, 'User groups configuration'); - + // Determine overall success $results['success'] = empty($results['errors']); if (!$results['success']) { $results['message'] = 'Auto-configuration completed with some issues'; } - + $this->logger->info('SettingsService: Consolidated auto-configuration completed', [ 'success' => $results['success'], 'errors_count' => count($results['errors']) ]); - + return $results; } @@ -2601,7 +2605,7 @@ private function loadConfiguration(bool $force): array { try { $importResult = $this->manualImport($force); - + return [ 'success' => $importResult['success'], 'message' => $importResult['message'] ?? 'Configuration loaded', @@ -2632,7 +2636,20 @@ private function configureVoorzieningen(): array ]; } - $registers = $objectService->getRegisters(); + try { + $registers = $objectService->getRegisters(); + } catch (\TypeError | \Exception $e) { + $this->logger->warning('OpenRegister getRegisters() failed in configureVoorzieningen', [ + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return [ + 'success' => false, + 'message' => 'Failed to retrieve registers: ' . $e->getMessage(), + ]; + } + if (empty($registers)) { return [ 'success' => false, @@ -2643,10 +2660,8 @@ private function configureVoorzieningen(): array // Find the voorzieningen register by slug OR by presence of expected schema slugs $targetRegister = null; $expectedSlugs = [ - 'organisatie', 'contactpersoon', 'voorziening', 'voorzieningaanbod', 'voorzieningversie', - 'kwetsbaarheid', 'contract', 'standaard', 'review', 'koppeling', 'beoordeeling', - 'voorzieningmodule', 'verklaring', 'koppelinggebruik', 'compliancy', 'modulegebruik', - 'moduleversie', 'sector' + 'sector', 'product', 'dienst', 'kwetsbaarheid', 'contactpersoon', 'organisatie', + 'gebruik', 'contract', 'koppeling', 'beoordeeling', 'module', 'compliancy', 'moduleversie', 'moduleVersie' ]; foreach ($registers as $register) { @@ -2670,35 +2685,72 @@ private function configureVoorzieningen(): array ]; } - // Map schema slugs to configuration keys (singular slug + _schema) + // Map schema slugs to configuration keys based on actual register schemas $slugToKey = [ 'organisatie' => 'organisatie_schema', 'contactpersoon' => 'contactpersoon_schema', - 'voorziening' => 'voorziening_schema', - 'voorzieningaanbod' => 'voorziening_aanbod_schema', - 'voorzieningversie' => 'voorziening_versie_schema', + 'product' => 'product_schema', + 'dienst' => 'dienst_schema', 'kwetsbaarheid' => 'kwetsbaarheid_schema', + 'gebruik' => 'gebruik_schema', 'contract' => 'contract_schema', - 'standaard' => 'standaard_schema', - 'review' => 'review_schema', 'koppeling' => 'koppeling_schema', 'beoordeeling' => 'beoordeeling_schema', - 'voorzieningmodule' => 'voorziening_module_schema', - 'verklaring' => 'verklaring_schema', - 'koppelinggebruik' => 'koppeling_gebruik_schema', + 'module' => 'module_schema', 'compliancy' => 'compliancy_schema', - 'modulegebruik' => 'module_gebruik_schema', - 'moduleversie' => 'module_versie_schema', + 'moduleversie' => 'moduleVersie_schema', // Handle both moduleversie and moduleVersie + 'moduleVersie' => 'moduleVersie_schema', 'sector' => 'sector_schema', ]; $config = [ 'register' => (string)($targetRegister['id'] ?? '') ]; + + $this->logger->info('DEBUG: About to process schemas', [ + 'register_id' => $targetRegister['id'], + 'schemas_count' => count($targetRegister['schemas'] ?? []), + 'slugToKey_map' => $slugToKey + ]); + foreach (($targetRegister['schemas'] ?? []) as $schema) { - $schemaSlug = strtolower($schema['slug'] ?? ''); - if (isset($slugToKey[$schemaSlug])) { - $config[$slugToKey[$schemaSlug]] = (string)$schema['id']; + $originalSlug = $schema['slug'] ?? ''; + $lowercaseSlug = strtolower($originalSlug); + + $this->logger->info('DEBUG: Processing schema', [ + 'original_slug' => $originalSlug, + 'lowercase_slug' => $lowercaseSlug, + 'schema_id' => $schema['id'] ?? 'NO_ID', + 'has_mapping_original' => isset($slugToKey[$originalSlug]) ? 'YES' : 'NO', + 'has_mapping_lowercase' => isset($slugToKey[$lowercaseSlug]) ? 'YES' : 'NO' + ]); + + $mappingKey = null; + $usedSlug = null; + + // Try original case first, then lowercase + if (isset($slugToKey[$originalSlug])) { + $mappingKey = $slugToKey[$originalSlug]; + $usedSlug = $originalSlug; + } elseif (isset($slugToKey[$lowercaseSlug])) { + $mappingKey = $slugToKey[$lowercaseSlug]; + $usedSlug = $lowercaseSlug; + } + + if ($mappingKey !== null) { + $config[$mappingKey] = (string)$schema['id']; + $this->logger->info('DEBUG: Mapped schema successfully', [ + 'used_slug' => $usedSlug, + 'config_key' => $mappingKey, + 'schema_id' => $schema['id'] + ]); + } else { + $this->logger->debug('DEBUG: No mapping found for schema slug', [ + 'original_slug' => $originalSlug, + 'lowercase_slug' => $lowercaseSlug + ]); } } + + $this->logger->info('DEBUG: Final config before persist', ['config' => $config]); // Persist normalized config $this->setVoorzieningenConfig($config); @@ -2734,7 +2786,20 @@ private function configureAmef(): array ]; } - $registers = $objectService->getRegisters(); + try { + $registers = $objectService->getRegisters(); + } catch (\TypeError | \Exception $e) { + $this->logger->warning('OpenRegister getRegisters() failed in configureAmef', [ + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + return [ + 'success' => false, + 'message' => 'Failed to retrieve registers: ' . $e->getMessage(), + ]; + } + if (empty($registers)) { return [ 'success' => false, @@ -2774,16 +2839,16 @@ private function configureAmef(): array 'relation_schema' => '', 'view_schema' => '', 'model_schema' => '', - 'property-definition_schema' => '', - 'property_schema' => '', - 'extendview_schema' => '', + 'property_definition_schema' => '', ]; foreach (($targetRegister['schemas'] ?? []) as $schema) { $slug = strtolower($schema['slug'] ?? ''); - $allowed = ['organization','element','relation','view','model','property','property-definition','extendview']; + $allowed = ['organization','element','relation','view','model','property-definition']; if (in_array($slug, $allowed, true)) { - $config[$slug . '_schema'] = (string)$schema['id']; + // Handle property-definition schema with underscore in config key + $configKey = $slug === 'property-definition' ? 'property_definition_schema' : $slug . '_schema'; + $config[$configKey] = (string)$schema['id']; } } @@ -2815,7 +2880,7 @@ private function configureGroups(): array try { // Call the method to create required user groups $result = $this->createAndConfigureUserGroups(); - + return [ 'success' => $result['success'], 'message' => $result['message'], @@ -2867,11 +2932,11 @@ public function getConsolidatedConfiguration(): array 'user_creation' => $this->getEmailTemplate('user_creation'), 'user_password' => $this->getEmailTemplate('user_password'), ]; - + // Get Voorzieningen and AMEF configs (without object counts for performance) $voorzieningenConfig = $this->getVoorzieningenConfig(); $amefConfig = $this->getAmefConfig(); - + return [ 'voorzieningen' => $voorzieningenConfig, 'amef' => $amefConfig, @@ -2917,6 +2982,9 @@ public function getVoorzieningenConfig(): array */ public function setVoorzieningenConfig(array $config): void { + // Clear cache since voorzieningen config affects schema and register IDs + $this->clearConfigurationCache(); + // Persist only normalized structure $normalized = $this->normalizeVoorzieningenConfig($config); $jsonConfig = json_encode($normalized, JSON_PRETTY_PRINT); @@ -2939,25 +3007,20 @@ private function normalizeVoorzieningenConfig(array $input): array // Register id $normalized['register'] = isset($input['register']) ? (string)$input['register'] : ''; - // Known schema keys to support (18 total) + // Known schema keys to support - updated to match actual schemas from register $schemaKeys = [ 'organisatie_schema', 'contactpersoon_schema', - 'voorziening_schema', - 'voorziening_aanbod_schema', - 'voorziening_versie_schema', + 'product_schema', + 'dienst_schema', 'kwetsbaarheid_schema', + 'gebruik_schema', 'contract_schema', - 'standaard_schema', - 'review_schema', 'koppeling_schema', 'beoordeeling_schema', - 'voorziening_module_schema', - 'verklaring_schema', - 'koppeling_gebruik_schema', + 'module_schema', 'compliancy_schema', - 'module_gebruik_schema', - 'module_versie_schema', + 'moduleVersie_schema', 'sector_schema', ]; @@ -2987,24 +3050,24 @@ public function getAmefConfig(): array try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + // Use reflection to access the private getAmefConfig method $reflection = new \ReflectionClass($archiMateService); $method = $reflection->getMethod('getAmefConfig'); $method->setAccessible(true); - + return $method->invoke($archiMateService); - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to get AMEF config from ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to direct config access if ArchiMateService is not available $config = $this->config->getValueString($this->_appName, 'amef_config', '{}'); $decoded = json_decode($config, true); - + if (!is_array($decoded)) { // Fallback to individual config values for backward compatibility $decoded = [ @@ -3013,11 +3076,10 @@ public function getAmefConfig(): array 'elements_schema' => $this->config->getValueString($this->_appName, 'amef_elements_schema', ''), 'relationships_schema' => $this->config->getValueString($this->_appName, 'amef_relationships_schema', ''), 'views_schema' => $this->config->getValueString($this->_appName, 'amef_views_schema', ''), - 'models_schema' => $this->config->getValueString($this->_appName, 'amef_models_schema', ''), - 'properties_schema' => $this->config->getValueString($this->_appName, 'amef_properties_schema', '') + 'models_schema' => $this->config->getValueString($this->_appName, 'amef_models_schema', '') ]; } - + return $decoded; } } @@ -3032,6 +3094,14 @@ public function setAmefConfig(array $config): void { $jsonConfig = json_encode($config, JSON_PRETTY_PRINT); $this->config->setValueString($this->_appName, 'amef_config', $jsonConfig); + + // Clear configuration cache when AMEF config is updated + $this->clearConfigurationCache(); + + $this->logger->debug('SettingsService: AMEF configuration updated and cache cleared', [ + 'config_keys' => array_keys($config), + 'cache_cleared' => true + ]); } /** @@ -3043,7 +3113,7 @@ public function getEmailConfig(): array { $config = $this->config->getValueString($this->_appName, 'email_config', '{}'); $decoded = json_decode($config, true); - + if (!is_array($decoded)) { // Fallback to individual config values for backward compatibility $decoded = [ @@ -3060,7 +3130,7 @@ public function getEmailConfig(): array 'mailjet_secret_key' => $this->config->getValueString($this->_appName, 'email_mailjet_secret_key', '') ]; } - + return $decoded; } @@ -3072,6 +3142,9 @@ public function getEmailConfig(): array */ public function setEmailConfig(array $config): void { + // Email config doesn't typically affect schema/register IDs, but clear cache for consistency + $this->clearConfigurationCache(); + $jsonConfig = json_encode($config, JSON_PRETTY_PRINT); $this->config->setValueString($this->_appName, 'email_config', $jsonConfig); } @@ -3089,32 +3162,33 @@ public function getArchiMateStatus(): array try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + return $archiMateService->getArchiMateStatus(); - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to get ArchiMate status from ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to direct config access if ArchiMateService is not available $importStatus = $this->config->getValueString($this->_appName, 'archimate_import_status', '{}'); $exportStatus = $this->config->getValueString($this->_appName, 'archimate_export_status', '{}'); - + $importDecoded = json_decode($importStatus, true); $exportDecoded = json_decode($exportStatus, true); - + // Get AMEF object counts $amefObjectCounts = $this->getAmefObjectCounts(); - + return [ 'import' => is_array($importDecoded) ? $importDecoded : [], 'export' => is_array($exportDecoded) ? $exportDecoded : [], 'totalElementObjects' => $amefObjectCounts['totalElementObjects'], 'totalOrganizationObjects' => $amefObjectCounts['totalOrganizationObjects'], 'totalViewObjects' => $amefObjectCounts['totalViewObjects'], - 'totalRelationshipsObjects' => $amefObjectCounts['totalRelationshipsObjects'] + 'totalRelationshipsObjects' => $amefObjectCounts['totalRelationshipsObjects'], + 'totalModelObjects' => $amefObjectCounts['totalModelObjects'] ]; } } @@ -3147,13 +3221,14 @@ private function getVoorzieningenObjectCounts(): array 'totalCompliancyObjects' => 0, 'totalModuleGebruikObjects' => 0, 'totalModuleVersieObjects' => 0, - 'totalSectorObjects' => 0 + 'totalSectorObjects' => 0, + 'totalGebruikObjects' => 0 ]; } - + $voorzieningenConfig = $this->getVoorzieningenConfig(); $registerId = $voorzieningenConfig['register'] ?? null; - + // Define all schema mappings $schemaMappings = [ 'organisatie_schema' => 'totalOrganisatieObjects', @@ -3173,20 +3248,21 @@ private function getVoorzieningenObjectCounts(): array 'compliancy_schema' => 'totalCompliancyObjects', 'module_gebruik_schema' => 'totalModuleGebruikObjects', 'module_versie_schema' => 'totalModuleVersieObjects', - 'sector_schema' => 'totalSectorObjects' + 'sector_schema' => 'totalSectorObjects', + 'gebruik_schema' => 'totalGebruikObjects' ]; - + $counts = []; - + // Initialize all counts to 0 foreach ($schemaMappings as $key => $countKey) { $counts[$countKey] = 0; } - + // Count objects for each configured schema foreach ($schemaMappings as $configKey => $countKey) { $schemaId = $voorzieningenConfig[$configKey] ?? null; - + if ($registerId && $schemaId) { try { $query = [ @@ -3202,17 +3278,17 @@ private function getVoorzieningenObjectCounts(): array } } } - + $this->logger->debug('SettingsService: Retrieved Voorzieningen object counts', $counts); - + return $counts; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to get Voorzieningen object counts', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + return [ 'totalOrganisatieObjects' => 0, 'totalContactpersoonObjects' => 0, @@ -3231,7 +3307,8 @@ private function getVoorzieningenObjectCounts(): array 'totalCompliancyObjects' => 0, 'totalModuleGebruikObjects' => 0, 'totalModuleVersieObjects' => 0, - 'totalSectorObjects' => 0 + 'totalSectorObjects' => 0, + 'totalGebruikObjects' => 0 ]; } } @@ -3249,22 +3326,20 @@ private function getAmefObjectCounts(): array try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + // Get object counts using ArchiMateService methods $elementObjects = $archiMateService->getElementObjects(); $organizationObjects = $archiMateService->getOrganizationObjects(); $viewObjects = $archiMateService->getViewObjects(); $relationshipObjects = $archiMateService->getRelationshipObjects(); $modelObjects = $archiMateService->getModelObjects(); - $propertyObjects = $archiMateService->getPropertyObjects(); $this->logger->debug('SettingsService: Retrieved AMEF object counts', [ 'elementObjects' => count($elementObjects), 'organizationObjects' => count($organizationObjects), 'viewObjects' => count($viewObjects), 'relationshipObjects' => count($relationshipObjects), - 'modelObjects' => count($modelObjects), - 'propertyObjects' => count($propertyObjects) + 'modelObjects' => count($modelObjects) ]); return [ @@ -3272,8 +3347,7 @@ private function getAmefObjectCounts(): array 'totalOrganizationObjects' => count($organizationObjects), 'totalViewObjects' => count($viewObjects), 'totalRelationshipsObjects' => count($relationshipObjects), - 'totalModelObjects' => count($modelObjects), - 'totalPropertyObjects' => count($propertyObjects) + 'totalModelObjects' => count($modelObjects) ]; } catch (\Exception $e) { @@ -3288,8 +3362,7 @@ private function getAmefObjectCounts(): array 'totalOrganizationObjects' => 0, 'totalViewObjects' => 0, 'totalRelationshipsObjects' => 0, - 'totalModelObjects' => 0, - 'totalPropertyObjects' => 0 + 'totalModelObjects' => 0 ]; } } @@ -3308,15 +3381,15 @@ public function setArchiMateImportStatus(array $status): void try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + $archiMateService->setArchiMateImportStatus($status); - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to set ArchiMate import status via ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to direct config access if ArchiMateService is not available $jsonStatus = json_encode($status, JSON_PRETTY_PRINT); $this->config->setValueString($this->_appName, 'archimate_import_status', $jsonStatus); @@ -3337,15 +3410,15 @@ public function setArchiMateExportStatus(array $status): void try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + $archiMateService->setArchiMateExportStatus($status); - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to set ArchiMate export status via ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to direct config access if ArchiMateService is not available $jsonStatus = json_encode($status, JSON_PRETTY_PRINT); $this->config->setValueString($this->_appName, 'archimate_export_status', $jsonStatus); @@ -3365,18 +3438,18 @@ public function clearArchiMateImportStatus(): array try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + return $archiMateService->clearArchiMateImportStatus(); - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to clear ArchiMate import status via ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to direct config access if ArchiMateService is not available $this->config->deleteKey($this->_appName, 'archimate_import_status'); - + return [ 'cleared' => true, 'process_killed' => false, @@ -3401,18 +3474,18 @@ public function killArchiMateImport(): array try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + return $archiMateService->clearArchiMateImportStatus(true); // killProcess = true - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to kill ArchiMate import process via ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to just clearing config if ArchiMateService is not available $this->config->deleteKey($this->_appName, 'archimate_import_status'); - + return [ 'cleared' => true, 'process_killed' => false, @@ -3436,18 +3509,18 @@ public function cancelArchiMateImport(): array try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + return $archiMateService->cancelArchiMateImport(); - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to cancel ArchiMate import via ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to just clearing config if ArchiMateService is not available $this->config->deleteKey($this->_appName, 'archimate_import_status'); - + return [ 'cancelled' => true, 'was_running' => false, @@ -3473,15 +3546,15 @@ public function clearArchiMateExportStatus(): void try { // Get ArchiMateService from container to avoid circular dependency $archiMateService = $this->container->get(\OCA\SoftwareCatalog\Service\ArchiMateService::class); - + $archiMateService->clearArchiMateExportStatus(); - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to clear ArchiMate export status via ArchiMateService', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); - + // Fallback to direct config access if ArchiMateService is not available $this->config->deleteKey($this->_appName, 'archimate_export_status'); } @@ -3524,7 +3597,6 @@ public function compactToJsonConfiguration(): array 'relationships_schema' => $this->config->getValueString($this->_appName, 'amef_relationships_schema', ''), 'views_schema' => $this->config->getValueString($this->_appName, 'amef_views_schema', ''), 'models_schema' => $this->config->getValueString($this->_appName, 'amef_models_schema', ''), - 'properties_schema' => $this->config->getValueString($this->_appName, 'amef_properties_schema', ''), 'organization_source' => $this->config->getValueString($this->_appName, 'amef_organization_source', 'openregister'), 'organization_register' => $this->config->getValueString($this->_appName, 'amef_organization_register', ''), 'organization_schema' => $this->config->getValueString($this->_appName, 'amef_organization_schema', ''), @@ -3600,7 +3672,7 @@ public function cleanupOldConfiguration(): array try { // List of old configuration keys to remove $oldKeys = [ - // Voorzieningen keys + // Voorzieningen keys - old individual keys 'voorzieningen_register', 'voorzieningen_organisatie_schema', 'voorzieningen_contactpersoon_schema', @@ -3615,12 +3687,26 @@ public function cleanupOldConfiguration(): array 'voorzieningen_gebruiker_register', // Deprecated - no longer used 'voorzieningen_contactgegevens_register', // Deprecated - no longer used - // AMEF keys + // Old Voorzieningen schema keys that no longer exist in register + 'voorzieningen_voorziening_schema', + 'voorzieningen_voorziening_aanbod_schema', + 'voorzieningen_voorziening_versie_schema', + 'voorzieningen_standaard_schema', + 'voorzieningen_review_schema', + 'voorzieningen_voorziening_module_schema', + 'voorzieningen_verklaring_schema', + 'voorzieningen_koppeling_gebruik_schema', + 'voorzieningen_module_gebruik_schema', + 'voorzieningen_module_versie_schema', + + // AMEF keys - old individual keys 'amef_register_id', 'amef_organizations_schema', 'amef_elements_schema', 'amef_relationships_schema', 'amef_views_schema', + 'amef_models_schema', + 'amef_property_definitions_schema', 'amef_organization_source', 'amef_organization_register', 'amef_organization_schema', @@ -3628,6 +3714,10 @@ public function cleanupOldConfiguration(): array 'amef_organizationss_schema', 'amef_relationshipss_schema', + // AMEF keys with hyphen format (old) + 'amef_property-definition_schema', + 'amef_extendview_schema', // No longer in register + // Email keys 'email_enabled', 'email_transport_type', @@ -3705,7 +3795,7 @@ public function getAllSettings(): array ]; return $result; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to get all settings', [ 'exception' => $e->getMessage() @@ -3730,12 +3820,12 @@ public function getObjectCountsStatistics(): array 'amef' => [], 'timestamp' => time() ]; - + // Get Voorzieningen statistics try { $voorzieningenConfig = $this->getVoorzieningenConfig(); $voorzieningenCounts = $this->getVoorzieningenObjectCounts(); - + $statistics['voorzieningen'] = [ 'config' => $voorzieningenConfig, 'object_counts' => $voorzieningenCounts, @@ -3750,12 +3840,12 @@ public function getObjectCountsStatistics(): array 'error' => $e->getMessage() ]; } - + // Get AMEF statistics try { $amefConfig = $this->getAmefConfig(); $amefCounts = $this->getAmefObjectCounts(); - + $statistics['amef'] = [ 'config' => $amefConfig, 'object_counts' => $amefCounts, @@ -3770,16 +3860,15 @@ public function getObjectCountsStatistics(): array 'totalOrganizationObjects' => 0, 'totalViewObjects' => 0, 'totalRelationshipsObjects' => 0, - 'totalModelObjects' => 0, - 'totalPropertyObjects' => 0 + 'totalModelObjects' => 0 ], 'configured' => false, 'error' => $e->getMessage() ]; } - + return $statistics; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to get object counts statistics', [ 'exception' => $e->getMessage() @@ -3798,8 +3887,7 @@ public function getObjectCountsStatistics(): array 'totalOrganizationObjects' => 0, 'totalViewObjects' => 0, 'totalRelationshipsObjects' => 0, - 'totalModelObjects' => 0, - 'totalPropertyObjects' => 0 + 'totalModelObjects' => 0 ], 'configured' => false, 'error' => $e->getMessage() @@ -3818,9 +3906,9 @@ public function getObjectCountsStatistics(): array */ public function getAllEmailTemplates(): array { - $templateTypes = ['organization_registration', 'organization_activation', 'user_creation', 'user_password']; + $templateTypes = ['organization_registration', 'organization_activation', 'user_creation', 'user_password', 'user_organisation']; $templates = []; - + foreach ($templateTypes as $templateName) { try { $templates[$templateName] = $this->getEmailTemplate($templateName); @@ -3829,7 +3917,7 @@ public function getAllEmailTemplates(): array $templates[$templateName] = null; } } - + return $templates; } @@ -3843,7 +3931,7 @@ public function updateGenericUserGroups(array $groups): array { try { $validation = $this->validateGroups($groups); - + if (!empty($validation['invalid'])) { return [ 'success' => false, @@ -3851,15 +3939,15 @@ public function updateGenericUserGroups(array $groups): array 'validation' => $validation ]; } - + $this->setGenericUserGroups($validation['valid']); - + return [ 'success' => true, 'message' => 'Generic user groups updated successfully', 'groups' => $validation['valid'] ]; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to update generic user groups', [ 'exception' => $e->getMessage() @@ -3881,7 +3969,7 @@ public function updateOrganizationAdminGroups(array $groups): array { try { $validation = $this->validateGroups($groups); - + if (!empty($validation['invalid'])) { return [ 'success' => false, @@ -3889,15 +3977,15 @@ public function updateOrganizationAdminGroups(array $groups): array 'validation' => $validation ]; } - + $this->setOrganizationAdminGroups($validation['valid']); - + return [ 'success' => true, 'message' => 'Organization admin groups updated successfully', 'groups' => $validation['valid'] ]; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to update organization admin groups', [ 'exception' => $e->getMessage() @@ -3919,7 +4007,7 @@ public function updateSuperUserGroups(array $groups): array { try { $validation = $this->validateGroups($groups); - + if (!empty($validation['invalid'])) { return [ 'success' => false, @@ -3927,15 +4015,15 @@ public function updateSuperUserGroups(array $groups): array 'validation' => $validation ]; } - + $this->setSuperUserGroups($validation['valid']); - + return [ 'success' => true, 'message' => 'Super user groups updated successfully', 'groups' => $validation['valid'] ]; - + } catch (\Exception $e) { $this->logger->error('SettingsService: Failed to update super user groups', [ 'exception' => $e->getMessage() @@ -3961,7 +4049,7 @@ public function getArchiMateConfig(): array try { $config = $this->getAmefConfig(); $status = $this->getArchiMateStatus(); - + return [ 'success' => true, 'config' => $config, @@ -3990,7 +4078,7 @@ public function updateArchiMateConfig(array $config): array { try { $this->setAmefConfig($config); - + return [ 'success' => true, 'message' => 'ArchiMate configuration updated successfully', @@ -4018,7 +4106,7 @@ public function getEmailConfigFocused(): array try { $emailSettings = $this->getEmailSettings(); $emailTemplates = $this->getAllEmailTemplates(); - + return [ 'success' => true, 'emailSettings' => $emailSettings, @@ -4046,13 +4134,13 @@ public function getEmailConfigFocused(): array public function updateEmailConfig(array $config): array { try { - if (isset($config['emailSettings'])) { - $result = $this->updateEmailSettings($config['emailSettings']); + if (isset($config)) { + $result = $this->updateEmailSettings($config); if (!$result['success']) { return $result; } } - + return [ 'success' => true, 'message' => 'Email configuration updated successfully', @@ -4079,7 +4167,7 @@ public function getAmefConfigFocused(): array { try { $config = $this->getAmefConfig(); - + return [ 'success' => true, 'config' => $config, @@ -4127,8 +4215,7 @@ public function updateAmefConfig(array $config): array 'relation_schema', 'view_schema', 'model_schema', - 'property-definition_schema', - 'property_schema', + 'property_definition_schema', ]; $validated = []; @@ -4136,15 +4223,25 @@ public function updateAmefConfig(array $config): array $objectService = $this->getObjectService(); if ($objectService !== null) { // Build a set of schema ids for the chosen register - $registers = $objectService->getRegisters(); - $schemaIdSet = []; - foreach ($registers as $register) { - if ((string)($register['id'] ?? '') === $targetRegisterId) { - foreach (($register['schemas'] ?? []) as $schema) { - $schemaIdSet[(string)$schema['id']] = true; + try { + $registers = $objectService->getRegisters(); + $schemaIdSet = []; + foreach ($registers as $register) { + if ((string)($register['id'] ?? '') === $targetRegisterId) { + foreach (($register['schemas'] ?? []) as $schema) { + $schemaIdSet[(string)$schema['id']] = true; + } + break; } - break; } + } catch (\TypeError | \Exception $e) { + $this->logger->warning('OpenRegister getRegisters() failed in updateAmefConfig', [ + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + // Continue with empty schema set which will cause validation to fail gracefully + $schemaIdSet = []; } // Validate each provided schema id against the chosen register @@ -4181,7 +4278,7 @@ public function updateAmefConfig(array $config): array } $this->setAmefConfig($merged); - + return [ 'success' => true, 'message' => 'AMEF configuration updated successfully', @@ -4208,7 +4305,7 @@ public function getVoorzieningenConfigFocused(): array { try { $config = $this->getVoorzieningenConfig(); - + return [ 'success' => true, 'config' => $config, @@ -4236,7 +4333,7 @@ public function updateVoorzieningenConfig(array $config): array { try { $this->setVoorzieningenConfig($config); - + return [ 'success' => true, 'message' => 'Voorzieningen configuration updated successfully', @@ -4267,7 +4364,7 @@ public function getObjectsCounts(): array 'amef' => $this->getAmefObjectCounts(), 'timestamp' => time() ]; - + return [ 'success' => true, 'counts' => $counts @@ -4292,7 +4389,7 @@ public function getObjectsStatistics(): array { try { $statistics = $this->getObjectCountsStatistics(); - + return [ 'success' => true, 'statistics' => $statistics @@ -4322,7 +4419,7 @@ public function getUserGroupsConfig(): array 'superUser' => $this->getSuperUserGroups(), 'allGroups' => $this->getAllGroups() ]; - + return [ 'success' => true, 'config' => $config, @@ -4350,24 +4447,24 @@ public function updateUserGroupsConfig(array $config): array { try { $results = []; - + if (isset($config['generic'])) { $results['generic'] = $this->updateGenericUserGroups($config['generic']); } - + if (isset($config['organizationAdmin'])) { $results['organizationAdmin'] = $this->updateOrganizationAdminGroups($config['organizationAdmin']); } - + if (isset($config['superUser'])) { $results['superUser'] = $this->updateSuperUserGroups($config['superUser']); } - + // Check if any updates failed $failed = array_filter($results, function($result) { return !$result['success']; }); - + if (!empty($failed)) { return [ 'success' => false, @@ -4375,7 +4472,7 @@ public function updateUserGroupsConfig(array $config): array 'results' => $results ]; } - + return [ 'success' => true, 'message' => 'User groups configuration updated successfully', @@ -4393,4 +4490,25 @@ public function updateUserGroupsConfig(array $config): array } } -} \ No newline at end of file + /** + * Get catalog location + * + * @return string The catalog location URL + */ + public function getCatalogLocation(): string + { + return $this->config->getValueString($this->_appName, 'catalog_location', ''); + } + + /** + * Set catalog location + * + * @param string $location The catalog location URL + * @return void + */ + public function setCatalogLocation(string $location): void + { + $this->config->setValueString($this->_appName, 'catalog_location', $location); + } + +} diff --git a/lib/Service/SoftwareCatalogue/ContactPersonHandler.php b/lib/Service/SoftwareCatalogue/ContactPersonHandler.php index 2a468bbd..eb92b9aa 100644 --- a/lib/Service/SoftwareCatalogue/ContactPersonHandler.php +++ b/lib/Service/SoftwareCatalogue/ContactPersonHandler.php @@ -18,6 +18,7 @@ namespace OCA\SoftwareCatalog\Service\SoftwareCatalogue; +use OCP\IConfig; use OCP\IUserManager; use OCP\IUser; use OCP\Security\ISecureRandom; @@ -44,25 +45,27 @@ class ContactPersonHandler /** * ContactPersonHandler constructor * - * @param IUserManager $_userManager User manager interface - * @param ISecureRandom $_secureRandom Secure random generator - * @param IGroupManager $_groupManager Group manager interface - * @param IAppConfig $_config Config interface - * @param ContainerInterface $_container Container interface - * @param IAppManager $_appManager App manager interface - * @param LoggerInterface $_logger Logger interface - * @param SymfonyEmailService $_emailService Email service + * @param IUserManager $_userManager User manager interface + * @param ISecureRandom $_secureRandom Secure random generator + * @param IGroupManager $_groupManager Group manager interface + * @param IAppConfig $_config Config interface + * @param ContainerInterface $_container Container interface + * @param IAppManager $_appManager App manager interface + * @param LoggerInterface $_logger Logger interface + * @param SymfonyEmailService $_emailService Email service */ public function __construct( - private readonly IUserManager $_userManager, - private readonly ISecureRandom $_secureRandom, - private readonly IGroupManager $_groupManager, - private readonly IAppConfig $_config, - private readonly ContainerInterface $_container, - private readonly IAppManager $_appManager, - private readonly LoggerInterface $_logger, + private readonly IUserManager $_userManager, + private readonly ISecureRandom $_secureRandom, + private readonly IGroupManager $_groupManager, + private readonly IAppConfig $_config, + private readonly ContainerInterface $_container, + private readonly IAppManager $_appManager, + private readonly LoggerInterface $_logger, private readonly SymfonyEmailService $_emailService, - ) { + private readonly IConfig $config, + ) + { } /** @@ -81,12 +84,11 @@ private function _getObjectService(): ?\OCA\OpenRegister\Service\ObjectService } - /** * Generates a username from contact data with fallback strategies * * @param array $contactData The contact data array - * + * * @return string Generated username */ public function generateUsernameFromContactData(array $contactData): string @@ -97,7 +99,6 @@ public function generateUsernameFromContactData(array $contactData): string $tussenvoegsel = $contactData['tussenvoegsel'] ?? ''; $achternaam = $contactData['achternaam'] ?? ''; $email = $contactData['email'] ?? $contactData['e-mailadres'] ?? ''; - // Strategy 1: full email address (PRIORITY) @@ -134,7 +135,7 @@ public function generateUsernameFromContactData(array $contactData): string $this->_logger->error('All username generation strategies failed', ['contactData' => $contactData]); return ''; } - + /** * Validates if a username meets Nextcloud requirements */ @@ -143,25 +144,25 @@ private function isValidUsername(string $username): bool if (empty($username)) { return false; } - + // Basic validation rules (adjust based on your Nextcloud configuration) if (strlen($username) < 3 || strlen($username) > 64) { return false; } - + // Must start with alphanumeric if (!preg_match('/^[a-z0-9]/', $username)) { return false; } - + // Only allow alphanumeric, dots, underscores, dashes, and @ symbol (for email addresses) if (!preg_match('/^[a-z0-9._@-]+$/', $username)) { return false; } - + return true; } - + /** * Ensures username is unique by adding counter if needed */ @@ -169,19 +170,19 @@ private function ensureUniqueUsername(string $username): string { $originalUsername = $username; $counter = 1; - + while ($this->_userManager->userExists($username)) { $username = $originalUsername . $counter; $counter++; - + // Safety check to prevent infinite loop if ($counter > 9999) { $username = $originalUsername . uniqid(); break; } } - + return $username; } @@ -189,130 +190,213 @@ private function ensureUniqueUsername(string $username): string * Creates a user account for a contact person * * @param object $contactpersoonObject The contact person object - * + * * @return \OCP\IUser|null The created user or null if failed */ public function createUserAccount(object $contactpersoonObject, bool $isFirstContact = false): ?\OCP\IUser { + $startTime = microtime(true); + try { $objectData = $contactpersoonObject->getObject(); + $contactId = $contactpersoonObject->getId(); $email = $objectData['email'] ?? $objectData['e-mailadres'] ?? ''; - + $organizationUuid = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; + + $this->_logger->critical('šŸ”„ USER ACCOUNT CREATION STARTED', [ + 'app' => 'softwarecatalog', + 'contactId' => $contactId, + 'email' => $email, + 'organizationUuid' => $organizationUuid, + 'isFirstContact' => $isFirstContact, + 'timestamp' => date('Y-m-d H:i:s'), + 'microtime' => microtime(true) + ]); + if (empty($email)) { - $this->_logger->warning( - 'Cannot create user account: no email address provided', - ['contactpersoonId' => $contactpersoonObject->getId()] - ); + $this->_logger->error('āŒ USER CREATION FAILED - NO EMAIL', [ + 'app' => 'softwarecatalog', + 'contactpersoonId' => $contactId + ]); return null; } - + // Generate username first to check both email and username existence $username = $objectData['username'] ?? ''; if (empty($username)) { + $this->_logger->info('[USER] Step 1: Generating username', [ + 'contactId' => $contactId, + 'email' => $email + ]); $username = $this->generateUsernameFromContactData($objectData); - + $this->_logger->critical('šŸ“ USERNAME GENERATED', [ + 'app' => 'softwarecatalog', + 'contactId' => $contactId, + 'generatedUsername' => $username, + 'email' => $email + ]); } - + // Check if user already exists by email + $this->_logger->info('[USER] Step 2: Checking existing user by email', [ + 'email' => $email + ]); if ($this->_userManager->userExists($email)) { - $this->_logger->info( - 'User already exists with email', - ['email' => $email, 'contactpersoonId' => $contactpersoonObject->getId()] - ); + $this->_logger->critical('ā™»ļø USER EXISTS BY EMAIL', [ + 'app' => 'softwarecatalog', + 'email' => $email, + 'contactpersoonId' => $contactId + ]); + $existingUser = $this->_userManager->get($email); if ($existingUser) { // Store organization UUID for existing user - $organizationUuid = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; if (!empty($organizationUuid)) { $this->storeUserOrganizationUuid($existingUser, $organizationUuid); } - + // Update groups for existing user $this->assignUserGroups($existingUser, $objectData, $isFirstContact); + + $this->_logger->critical('āœ… EXISTING USER UPDATED', [ + 'app' => 'softwarecatalog', + 'username' => $existingUser->getUID(), + 'email' => $email, + 'organizationUuid' => $organizationUuid + ]); + return $existingUser; } } - + // Check if user already exists by username + $this->_logger->info('[USER] Step 3: Checking existing user by username', [ + 'username' => $username + ]); $existingUserByUsername = $this->_userManager->get($username); if ($existingUserByUsername) { - $this->_logger->info( - 'User already exists with username', - ['username' => $username, 'contactpersoonId' => $contactpersoonObject->getId()] - ); - + $this->_logger->critical('ā™»ļø USER EXISTS BY USERNAME', [ + 'app' => 'softwarecatalog', + 'username' => $username, + 'contactpersoonId' => $contactId + ]); + // Store organization UUID for existing user - $organizationUuid = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; if (!empty($organizationUuid)) { $this->storeUserOrganizationUuid($existingUserByUsername, $organizationUuid); } - + // Update groups for existing user $this->assignUserGroups($existingUserByUsername, $objectData, $isFirstContact); + + $this->_logger->critical('āœ… EXISTING USER UPDATED BY USERNAME', [ + 'app' => 'softwarecatalog', + 'username' => $username, + 'email' => $existingUserByUsername->getEMailAddress(), + 'organizationUuid' => $organizationUuid + ]); + return $existingUserByUsername; } - - // Username already generated above for existence checks - - // Create user account + + // Create new user account + $this->_logger->critical('šŸš€ CREATING NEW USER ACCOUNT', [ + 'app' => 'softwarecatalog', + 'username' => $username, + 'email' => $email, + 'contactId' => $contactId + ]); + $user = $this->_userManager->createUser($username, $username); - + if ($user) { - + $this->_logger->critical('šŸŽŠ NEW USER ACCOUNT CREATED', [ + 'app' => 'softwarecatalog', + 'username' => $username, + 'email' => $email, + 'contactId' => $contactId, + 'userId' => $user->getUID() + ]); + // Set user details + $this->_logger->info('[USER] Step 4: Setting user details', [ + 'username' => $username + ]); $user->setEMailAddress($email); - $user->setDisplayName($this->getDisplayNameFromContactData($objectData)); + $displayName = $this->getDisplayNameFromContactData($objectData); + $user->setDisplayName($displayName); + $this->_logger->critical('šŸ“‹ USER DETAILS SET', [ + 'app' => 'softwarecatalog', + 'username' => $username, + 'email' => $email, + 'displayName' => $displayName + ]); + // Store organization UUID in user config for OpenConnector access - $organizationUuid = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; if (!empty($organizationUuid)) { + $this->_logger->info('[USER] Step 5: Storing organization UUID', [ + 'username' => $username, + 'organizationUuid' => $organizationUuid + ]); $this->storeUserOrganizationUuid($user, $organizationUuid); } - + // Set user groups based on roles and organization + $this->_logger->info('[USER] Step 6: Assigning user groups', [ + 'username' => $username, + 'isFirstContact' => $isFirstContact + ]); $this->assignUserGroups($user, $objectData, $isFirstContact); - + // Update contactpersoon with username $objectData['username'] = $username; $contactpersoonObject->setObject($objectData); - + // Send user creation email + $this->_logger->info('[USER] Step 7: Sending user creation email', [ + 'username' => $username, + 'email' => $email + ]); $this->sendUserCreationEmail($user, $objectData); - - $this->_logger->info( - 'Created user account for contact person', - [ - 'contactpersoonId' => $contactpersoonObject->getId(), - 'username' => $username, - 'email' => $email - ] - ); - + + $creationTime = round(microtime(true) - $startTime, 3); + $this->_logger->critical('šŸŽ‰ USER ACCOUNT CREATION COMPLETED', [ + 'app' => 'softwarecatalog', + 'contactpersoonId' => $contactId, + 'username' => $username, + 'email' => $email, + 'displayName' => $displayName, + 'organizationUuid' => $organizationUuid, + 'creationTime' => $creationTime . 's' + ]); + return $user; } else { - $this->_logger->error( - 'DEBUG: User creation returned null (no exception thrown)', - [ - 'username' => $username, - 'email' => $email, - 'contactpersoonId' => $contactpersoonObject->getId() - ] - ); + $this->_logger->error('āŒ USER CREATION RETURNED NULL', [ + 'app' => 'softwarecatalog', + 'username' => $username, + 'email' => $email, + 'contactpersoonId' => $contactId, + 'note' => 'No exception thrown but createUser returned null' + ]); } - + return null; - + } catch (\Exception $e) { - $this->_logger->error( - 'Failed to create user account: ' . $e->getMessage(), - [ - 'contactpersoonId' => $contactpersoonObject->getId(), - 'exception' => $e, - 'exception_class' => get_class($e), - 'exception_code' => $e->getCode(), - 'trace' => $e->getTraceAsString() - ] - ); + $this->_logger->error('šŸ’„ USER CREATION EXCEPTION', [ + 'app' => 'softwarecatalog', + 'contactpersoonId' => $contactpersoonObject->getId(), + 'email' => $objectData['email'] ?? 'unknown', + 'username' => $username ?? 'unknown', + 'exception' => $e->getMessage(), + 'exception_class' => get_class($e), + 'exception_code' => $e->getCode(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); return null; } } @@ -320,9 +404,9 @@ public function createUserAccount(object $contactpersoonObject, bool $isFirstCon /** * Assigns user groups based on roles and organization * - * @param \OCP\IUser $user The user to assign groups to - * @param array $objectData The contact person data - * + * @param \OCP\IUser $user The user to assign groups to + * @param array $objectData The contact person data + * * @return void */ private function assignUserGroups(\OCP\IUser $user, array $objectData, bool $isFirstContact = false): void @@ -330,23 +414,21 @@ private function assignUserGroups(\OCP\IUser $user, array $objectData, bool $isF try { $roles = $objectData['roles'] ?? []; $organizationId = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; - - // Ensure roles is an array if (!is_array($roles)) { $roles = [$roles]; } - + // Get the settings service to access group configurations $settingsService = $this->_container->get('OCA\SoftwareCatalog\Service\SettingsService'); - + // Add user to ALL generic user groups (as requested) $genericGroups = $settingsService->getGenericUserGroups(); foreach ($genericGroups as $groupName) { $this->addUserToGroup($user, $groupName, 'generic-user-group'); } - + // Add user to organization admin groups if this is the first contact if ($isFirstContact) { $organizationAdminGroups = $settingsService->getOrganizationAdminGroups(); @@ -354,14 +436,14 @@ private function assignUserGroups(\OCP\IUser $user, array $objectData, bool $isF $this->addUserToGroup($user, $groupName, 'organization-admin'); } } - + // Add user to organization group if available if (!empty($organizationId)) { $organizationGroup = $this->getOrganizationGroup((string)$organizationId); - + if ($organizationGroup && !$organizationGroup->inGroup($user)) { $organizationGroup->addUser($user); - + // If this is the first contact, make them a subadmin of the organization group if ($isFirstContact) { try { @@ -397,22 +479,20 @@ private function assignUserGroups(\OCP\IUser $user, array $objectData, bool $isF ); } } - - // Check if organization is of type "Gemeente" and add to "ambtenaar" group + + // Note: Removed automatic assignment of 'ambtenaar' group for gemeente organizations + // Users can be manually assigned to 'ambtenaar' group if needed $organizationType = $this->getOrganizationType((string)$organizationId); - if (strtolower($organizationType) === 'gemeente') { - $this->addUserToGroup($user, 'ambtenaar', 'gemeente-organization'); - $this->_logger->info( - 'Added user to ambtenaar group due to Gemeente organization type', - [ - 'username' => $user->getUID(), - 'organizationId' => $organizationId, - 'organizationType' => $organizationType - ] - ); - } + $this->_logger->debug( + 'Organization type detected (no automatic group assignment)', + [ + 'username' => $user->getUID(), + 'organizationId' => $organizationId, + 'organizationType' => $organizationType + ] + ); } - + $this->_logger->info( 'Successfully assigned user groups', [ @@ -422,7 +502,7 @@ private function assignUserGroups(\OCP\IUser $user, array $objectData, bool $isF 'organizationAdminGroups' => $isFirstContact ? ($organizationAdminGroups ?? []) : [] ] ); - + } catch (\Exception $e) { $this->_logger->error( 'Failed to assign user groups: ' . $e->getMessage(), @@ -455,10 +535,10 @@ private function getAllowedRoleGroups(): array /** * Adds a user to a group, creating the group if it doesn't exist * - * @param \OCP\IUser $user The user to add - * @param string $groupName The group name - * @param string $type The type of group assignment (for logging) - * + * @param \OCP\IUser $user The user to add + * @param string $groupName The group name + * @param string $type The type of group assignment (for logging) + * * @return void */ private function addUserToGroup(\OCP\IUser $user, string $groupName, string $type): void @@ -474,7 +554,7 @@ private function addUserToGroup(\OCP\IUser $user, string $groupName, string $typ ); } } - + if ($group && !$group->inGroup($user)) { $group->addUser($user); $this->_logger->info( @@ -502,17 +582,17 @@ private function addUserToGroup(\OCP\IUser $user, string $groupName, string $typ /** * Updates user groups when roles change (handles role removal) * - * @param \OCP\IUser $user The user to update - * @param array $newRoles The new roles - * @param array $oldRoles The old roles (optional) - * + * @param \OCP\IUser $user The user to update + * @param array $newRoles The new roles + * @param array $oldRoles The old roles (optional) + * * @return void */ public function updateUserGroupsFromRoles(\OCP\IUser $user, array $newRoles, array $oldRoles = []): void { try { $allowedGroups = $this->getAllowedRoleGroups(); - + // Remove user from groups for roles they no longer have if (!empty($oldRoles)) { $removedRoles = array_diff($oldRoles, $newRoles); @@ -534,7 +614,7 @@ public function updateUserGroupsFromRoles(\OCP\IUser $user, array $newRoles, arr } } } - + // Add user to groups for new roles foreach ($newRoles as $role) { if (in_array($role, array_keys($allowedGroups))) { @@ -542,11 +622,10 @@ public function updateUserGroupsFromRoles(\OCP\IUser $user, array $newRoles, arr $this->addUserToGroup($user, $groupName, 'role-update'); } } - - // Ensure organization type-based groups are preserved - // (e.g., "ambtenaar" for Gemeente organizations) - $this->ensureOrganizationTypeGroups($user); - + + // Note: Organization type-based automatic group assignments have been removed + // Groups like "ambtenaar" are now available for manual assignment only + } catch (\Exception $e) { $this->_logger->error( 'Failed to update user groups from roles: ' . $e->getMessage(), @@ -558,50 +637,13 @@ public function updateUserGroupsFromRoles(\OCP\IUser $user, array $newRoles, arr } } - /** - * Ensures organization type-based groups are assigned to the user - * - * @param \OCP\IUser $user The user to check and update - * - * @return void - */ - private function ensureOrganizationTypeGroups(\OCP\IUser $user): void - { - try { - // Find the user's organization by looking for their contactpersoon - $objectService = $this->_getObjectService(); - $contactpersoon = $this->findContactpersoonByUsername($user->getUID()); - - if ($contactpersoon) { - $contactData = $contactpersoon->getObject(); - $organizationId = $contactData['organisation'] ?? ''; - - if (!empty($organizationId)) { - $organizationType = $this->getOrganizationType($organizationId); - - // If organization is Gemeente, ensure user is in ambtenaar group - if (strtolower($organizationType) === 'gemeente') { - $this->addUserToGroup($user, 'ambtenaar', 'gemeente-organization-preserve'); - } - } - } - - } catch (\Exception $e) { - $this->_logger->error( - 'Failed to ensure organization type groups: ' . $e->getMessage(), - [ - 'username' => $user->getUID(), - 'exception' => $e - ] - ); - } - } + /** * Finds contactpersoon object by username * * @param string $username The username to search for - * + * * @return object|null The contactpersoon object or null if not found */ private function findContactpersoonByUsername(string $username): ?object @@ -609,28 +651,28 @@ private function findContactpersoonByUsername(string $username): ?object try { $objectService = $this->_getObjectService(); $settingsService = $this->_container->get('OCA\SoftwareCatalog\Service\SettingsService'); - + // Get configuration values $registerId = $settingsService->getVoorzieningenRegisterId(); $contactpersoonSchemaId = $settingsService->getSchemaIdForObjectType('contactpersoon'); - + if (!$registerId || !$contactpersoonSchemaId) { throw new \Exception('Register or schema ID not configured for contactpersoon'); } - + // Search for contactpersoon with the given username $searchFilters = [ 'username' => $username ]; - + $results = $objectService->findAll($searchFilters, $registerId, $contactpersoonSchemaId); - + if (!empty($results)) { return $results[0]; // Return the first match } - + return null; - + } catch (\Exception $e) { $this->_logger->error( 'Failed to find contactpersoon by username: ' . $e->getMessage(), @@ -647,7 +689,7 @@ private function findContactpersoonByUsername(string $username): ?object * Gets the organization group for a given organization ID * * @param string $organizationId The organization ID - * + * * @return \OCP\IGroup|null The organization group or null if not found */ private function getOrganizationGroup(string $organizationId): ?\OCP\IGroup @@ -658,32 +700,32 @@ private function getOrganizationGroup(string $organizationId): ?\OCP\IGroup if (!$objectService) { return null; } - + // Get register and schema IDs dynamically from configuration $settingsService = $this->_container->get('OCA\SoftwareCatalog\Service\SettingsService'); $registerId = $settingsService->getVoorzieningenRegisterId(); $organisatieSchemaId = $settingsService->getSchemaIdForObjectType('organisatie'); - + if (!$registerId || !$organisatieSchemaId) { $this->_logger->warning('Register or schema ID not configured for organisatie'); return null; } - + // Use find() method with proper register/schema context $organizationObject = $objectService->find($organizationId, [], false, $registerId, $organisatieSchemaId); - + if ($organizationObject) { $organizationData = $organizationObject->getObject(); $groupId = $organizationData['group'] ?? ''; - + if (!empty($groupId)) { $group = $this->_groupManager->get($groupId); return $group; } } - + return null; - + } catch (\Exception $e) { $this->_logger->error( 'Failed to get organization group: ' . $e->getMessage(), @@ -700,8 +742,8 @@ private function getOrganizationGroup(string $organizationId): ?\OCP\IGroup * Determines if this contact object is the first contact for the organization * * @param object $contactObject The contact object being processed (contactpersoon) - * @param array $objectData The contact data - * + * @param array $objectData The contact data + * * @return bool True if this is the first contact for the organization */ private function isFirstContactForOrganization(object $contactObject, array $objectData): bool @@ -709,12 +751,12 @@ private function isFirstContactForOrganization(object $contactObject, array $obj try { $organizationId = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; $currentContactId = $contactObject->getId(); - + if (empty($organizationId)) { $this->_logger->warning('No organization ID found for contact object'); return false; } - + $this->_logger->info( 'Checking if contact is first for organization', [ @@ -722,18 +764,18 @@ private function isFirstContactForOrganization(object $contactObject, array $obj 'organizationId' => $organizationId ] ); - + // Simple approach: Check if any OTHER users exist with this organization UUID $objectService = $this->_getObjectService(); if (!$objectService) { $this->_logger->error('ObjectService not available for first contact check'); return false; } - + // Get settings for schema IDs $settingsService = $this->_container->get('OCA\SoftwareCatalog\Service\SettingsService'); $registerId = $settingsService->getVoorzieningenRegisterId(); - + // Check contactpersoon schema $contactpersoonSchemaId = $settingsService->getSchemaIdForObjectType('contactpersoon'); if ($contactpersoonSchemaId) { @@ -742,12 +784,12 @@ private function isFirstContactForOrganization(object $contactObject, array $obj $registerId, $contactpersoonSchemaId ); - + // Filter out the current contact being processed - $otherContacts = array_filter($existingContacts, function($contact) use ($currentContactId) { + $otherContacts = array_filter($existingContacts, function ($contact) use ($currentContactId) { return $contact->getId() !== $currentContactId; }); - + $this->_logger->info( 'Found existing contacts for organization', [ @@ -758,15 +800,15 @@ private function isFirstContactForOrganization(object $contactObject, array $obj 'isFirstContact' => empty($otherContacts) ] ); - + // If there are any OTHER existing contacts, this is not the first if (!empty($otherContacts)) { return false; } } - + return true; - + } catch (\Exception $e) { $this->_logger->error( 'Failed to determine if first contact: ' . $e->getMessage(), @@ -786,9 +828,9 @@ private function isFirstContactForOrganization(object $contactObject, array $obj * This method stores the organization UUID in the user's 'core' namespace * configuration, making it accessible to other apps like OpenConnector. * - * @param IUser $user The user object + * @param IUser $user The user object * @param string|int $organizationUuid The organization UUID (can be string or int) - * + * * @return void */ private function storeUserOrganizationUuid(IUser $user, string|int $organizationUuid): void @@ -797,14 +839,14 @@ private function storeUserOrganizationUuid(IUser $user, string|int $organization if (!empty($organizationUuid)) { // Convert to string to ensure consistent storage $organizationUuidStr = (string)$organizationUuid; - - $this->_config->setUserValue( + + $this->config->setUserValue( $user->getUID(), 'core', 'organisation', $organizationUuidStr ); - + $this->_logger->info( 'Stored organization UUID in user config', [ @@ -831,7 +873,7 @@ private function storeUserOrganizationUuid(IUser $user, string|int $organization * Gets a display name from contact data * * @param array $contactData The contact data - * + * * @return string The display name */ private function getDisplayNameFromContactData(array $contactData): string @@ -841,7 +883,7 @@ private function getDisplayNameFromContactData(array $contactData): string $contactData['tussenvoegsel'] ?? '', $contactData['achternaam'] ?? '' ]); - + return implode(' ', $parts) ?: ($contactData['email'] ?? $contactData['e-mailadres'] ?? 'Unknown User'); } @@ -849,7 +891,7 @@ private function getDisplayNameFromContactData(array $contactData): string * Handles new contact creation * * @param object $contactObject The contact object - * + * * @return void */ public function handleNewContact(object $contactObject): void @@ -877,7 +919,7 @@ public function handleNewContact(object $contactObject): void * Handles contact update * * @param object $contactObject The contact object - * + * * @return void */ public function handleContactUpdate(object $contactObject): void @@ -905,7 +947,7 @@ public function handleContactUpdate(object $contactObject): void * Handles contact deletion * * @param object $contactObject The contact object - * + * * @return void */ public function handleContactDeletion(object $contactObject): void @@ -924,10 +966,10 @@ public function handleContactDeletion(object $contactObject): void if ($user) { // Option 1: Delete the user account // $user->delete(); - + // Option 2: Just disable the user $user->setEnabled(false); - + $this->_logger->info( 'User account disabled due to contact deletion', [ @@ -935,7 +977,7 @@ public function handleContactDeletion(object $contactObject): void 'contactId' => $contactObject->getId() ] ); - + // Send account suspension notification email $this->sendAccountSuspensionEmail($user, $objectData); } @@ -956,9 +998,9 @@ public function handleContactDeletion(object $contactObject): void * Assigns beheerder role to a user * * @param object $contactpersoonObject The contactpersoon object - * @param string $username The username - * @param string $organizationUuid The organization UUID - * + * @param string $username The username + * @param string $organizationUuid The organization UUID + * * @return void */ public function assignBeheerderRole(object $contactpersoonObject, string $username, string $organizationUuid): void @@ -966,19 +1008,19 @@ public function assignBeheerderRole(object $contactpersoonObject, string $userna try { $objectData = $contactpersoonObject->getObject(); $currentRoles = $objectData['roles'] ?? []; - + if (!is_array($currentRoles)) { $currentRoles = []; } - + // Add beheerder role if not already present if (!in_array('beheerder', array_map('strtolower', $currentRoles))) { $currentRoles[] = 'beheerder'; - + // Update the contactpersoon object (but don't save to prevent event loops) $objectData['roles'] = $currentRoles; $contactpersoonObject->setObject($objectData); - + // Note: NOT saving the object here to prevent infinite event loops // The original API call/operation will handle persistence $this->_logger->info('Beheerder role added to contactpersoon object, but not saved to prevent event loops', [ @@ -987,20 +1029,20 @@ public function assignBeheerderRole(object $contactpersoonObject, string $userna 'updatedRoles' => $currentRoles, 'objectId' => $contactpersoonObject->getId() ]); - + // Add user to beheerder group $beheerderGroup = $this->_groupManager->get('beheerder'); if (!$beheerderGroup) { $beheerderGroup = $this->_groupManager->createGroup('beheerder'); } - + if ($beheerderGroup) { $user = $this->_userManager->get($username); if ($user && !$beheerderGroup->inGroup($user)) { $beheerderGroup->addUser($user); } } - + $this->_logger->info( 'Assigned beheerder role to first user in organization', [ @@ -1010,7 +1052,7 @@ public function assignBeheerderRole(object $contactpersoonObject, string $userna ] ); } - + } catch (\Exception $e) { $this->_logger->error( 'Failed to assign beheerder role: ' . $e->getMessage(), @@ -1026,9 +1068,9 @@ public function assignBeheerderRole(object $contactpersoonObject, string $userna /** * Sets a user's manager in Nextcloud * - * @param string $username The username + * @param string $username The username * @param string $managerUsername The manager's username - * + * * @return void */ public function setUserManager(string $username, string $managerUsername): void @@ -1036,7 +1078,7 @@ public function setUserManager(string $username, string $managerUsername): void try { $user = $this->_userManager->get($username); $manager = $this->_userManager->get($managerUsername); - + if (!$user || !$manager) { $this->_logger->warning( 'Cannot set manager - user or manager not found', @@ -1049,7 +1091,7 @@ public function setUserManager(string $username, string $managerUsername): void ); return; } - + // In Nextcloud, we can set this as a user preference or custom attribute // Since there's no built-in manager field, we'll use preferences \OC::$server->getConfig()->setUserValue( @@ -1058,7 +1100,7 @@ public function setUserManager(string $username, string $managerUsername): void 'manager', $managerUsername ); - + $this->_logger->info( 'Set user manager', [ @@ -1066,7 +1108,7 @@ public function setUserManager(string $username, string $managerUsername): void 'manager' => $managerUsername ] ); - + } catch (\Exception $e) { $this->_logger->error( 'Failed to set user manager: ' . $e->getMessage(), @@ -1083,7 +1125,7 @@ public function setUserManager(string $username, string $managerUsername): void * Gets a user's manager * * @param string $username The username - * + * * @return string|null The manager's username or null if not set */ public function getUserManager(string $username): ?string @@ -1095,9 +1137,9 @@ public function getUserManager(string $username): ?string 'manager', '' ); - + return !empty($manager) ? $manager : null; - + } catch (\Exception $e) { $this->_logger->error( 'Failed to get user manager: ' . $e->getMessage(), @@ -1114,7 +1156,7 @@ public function getUserManager(string $username): ?string * Gets the organization type for a given organization ID * * @param string $organizationId The organization ID - * + * * @return string The organization type or empty string if not found */ private function getOrganizationType(string $organizationId): string @@ -1122,27 +1164,27 @@ private function getOrganizationType(string $organizationId): string try { // Get the organization object to find its type $objectService = $this->_getObjectService(); - + // Get register and schema IDs dynamically from configuration $settingsService = $this->_container->get('OCA\SoftwareCatalog\Service\SettingsService'); $registerId = $settingsService->getVoorzieningenRegisterId(); $organisatieSchemaId = $settingsService->getSchemaIdForObjectType('organisatie'); - + if (!$registerId || !$organisatieSchemaId) { $this->_logger->warning('Register or schema ID not configured for organisatie'); return ''; } - + // Try to find by UUID first, then by database ID if needed $organizationObject = $objectService->find($organizationId, [], false, $registerId, $organisatieSchemaId); - + if ($organizationObject) { $organizationData = $organizationObject->getObject(); - return $organizationData['type'] ?? ''; + return strtolower($organizationData['type'] ?? ''); } - + return ''; - + } catch (\Exception $e) { $this->_logger->error( 'Failed to get organization type: ' . $e->getMessage(), @@ -1158,19 +1200,20 @@ private function getOrganizationType(string $organizationId): string /** * Sends user creation email * - * @param \OCP\IUser $user The created user - * @param array $objectData The contact person data - * + * @param \OCP\IUser $user The created user + * @param array $objectData The contact person data + * * @return void */ private function sendUserCreationEmail(\OCP\IUser $user, array $objectData): void { + try { $this->_logger->info('Sending user creation email', [ 'username' => $user->getUID(), 'email' => $user->getEMailAddress() ]); - + // Prepare user data for email $userData = [ 'username' => $user->getUID(), @@ -1180,7 +1223,7 @@ private function sendUserCreationEmail(\OCP\IUser $user, array $objectData): voi 'achternaam' => $objectData['achternaam'] ?? '', 'roles' => $objectData['roles'] ?? [] ]; - + // Get organization data if available $organizationData = []; $organizationId = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; @@ -1188,16 +1231,16 @@ private function sendUserCreationEmail(\OCP\IUser $user, array $objectData): voi try { $objectService = $this->_getObjectService(); // Get register and schema IDs dynamically from configuration - $settingsService = $this->_container->get('OCA\SoftwareCatalog\Service\SettingsService'); - $registerId = $settingsService->getVoorzieningenRegisterId(); - $organisatieSchemaId = $settingsService->getSchemaIdForObjectType('organisatie'); - - if (!$registerId || !$organisatieSchemaId) { - $this->_logger->warning('Register or schema ID not configured for organisatie'); - return; - } - - $organizationObject = $objectService->find($organizationId, [], false, $registerId, $organisatieSchemaId); + $settingsService = $this->_container->get('OCA\SoftwareCatalog\Service\SettingsService'); + $registerId = $settingsService->getVoorzieningenRegisterId(); + $organisatieSchemaId = $settingsService->getSchemaIdForObjectType('organisatie'); + + if (!$registerId || !$organisatieSchemaId) { + $this->_logger->warning('Register or schema ID not configured for organisatie'); + return; + } + + $organizationObject = $objectService->find($organizationId, [], false, $registerId, $organisatieSchemaId); if ($organizationObject) { $organizationData = $organizationObject->getObject(); $this->_logger->info('Retrieved organization data for email', [ @@ -1212,10 +1255,10 @@ private function sendUserCreationEmail(\OCP\IUser $user, array $objectData): voi ]); } } - + // Send user creation email $success = $this->_emailService->sendUserCreationEmail($userData, $organizationData); - + if ($success) { $this->_logger->info('User creation email sent successfully', [ 'username' => $user->getUID(), @@ -1227,15 +1270,15 @@ private function sendUserCreationEmail(\OCP\IUser $user, array $objectData): voi 'email' => $user->getEMailAddress() ]); } - - } catch (\Exception $e) { - $this->_logger->error('Exception sending user creation email: ' . $e->getMessage(), [ - 'username' => $user->getUID(), - 'email' => $user->getEMailAddress(), - 'exception' => $e - ]); - } - } + + } catch (\Exception $e) { + $this->_logger->error('Exception sending user creation email: ' . $e->getMessage(), [ + 'username' => $user->getUID(), + 'email' => $user->getEMailAddress(), + 'exception' => $e + ]); + } + } /** * Processes a contactpersoon object to create an inactive user @@ -1244,8 +1287,8 @@ private function sendUserCreationEmail(\OCP\IUser $user, array $objectData): voi * this method will create an inactive user account and set the username property. * * @param object $contactpersoonObject The contactpersoon object to process - * @param bool $isUpdate Whether this is an update operation (defaults to false) - * + * @param bool $isUpdate Whether this is an update operation (defaults to false) + * * @return bool True if processing was successful * @throws \Exception If processing fails */ @@ -1259,99 +1302,99 @@ public function processContactpersoon(object $contactpersoonObject, bool $isUpda // Get object data $objectData = $contactpersoonObject->getObject(); - + // Check if username exists and is filled $username = $objectData['username'] ?? ''; - + if (empty($username)) { $this->_logger->info('Username not found or empty, creating inactive user account'); - + // Generate username from name fields $username = $this->generateUsernameFromContactData($objectData); - + // For updates, try to find existing user first to avoid expensive isFirstContactForOrganization check if ($isUpdate) { $existingUser = $this->_userManager->get($username); - + if ($existingUser) { $this->_logger->info('Found existing user during update, skipping expensive first contact check', [ 'username' => $username, 'objectId' => $contactpersoonObject->getId() ]); - + // Update the contactpersoon object with the username (but don't save to prevent event loops) $objectData['username'] = $username; $contactpersoonObject->setObject($objectData); - + $this->_logger->info('Username added to contactpersoon object during update, but not saved to prevent event loops', [ 'username' => $username, 'objectId' => $contactpersoonObject->getId() ]); - + // Ensure contactpersoon is added to organization $this->ensureContactpersoonInOrganization($contactpersoonObject); - + return true; } } - + // Determine if this is the first contact for the organization (expensive operation) $isFirstContact = $this->isFirstContactForOrganization($contactpersoonObject, $objectData); - + // Create the user account $user = $this->createUserAccount($contactpersoonObject, $isFirstContact); - + if ($user === null) { throw new \Exception('Failed to create user account'); } - + // Set user to inactive initially $this->setUserInactive($user->getUID()); - + // Update the contactpersoon object with the username (but don't save to prevent event loops) $objectData['username'] = $username; $contactpersoonObject->setObject($objectData); - + // Note: NOT saving the object here to prevent infinite event loops // The original API call/operation will handle persistence $this->_logger->info('Username added to contactpersoon object, but not saved to prevent event loops', [ 'username' => $username, 'objectId' => $contactpersoonObject->getId() ]); - + // Ensure contactpersoon is added to organization $this->ensureContactpersoonInOrganization($contactpersoonObject); - + // Also add user to organization entity (OpenRegister entity, not object) $this->addUserToOrganizationEntity($contactpersoonObject, $username); - + $this->_logger->info( - 'Successfully created inactive user and updated contactpersoon', + 'Successfully created inactive user and updated contactpersoon', [ 'username' => $username, 'objectId' => $contactpersoonObject->getId() ] ); - + return true; } - + $this->_logger->info( - 'Username already exists, contactpersoon processed', + 'Username already exists, contactpersoon processed', [ 'username' => $username, 'objectId' => $contactpersoonObject->getId() ] ); - + // Ensure contactpersoon is added to organization (even for existing users) $this->ensureContactpersoonInOrganization($contactpersoonObject); - + return true; - + } catch (\Exception $e) { $this->_logger->error( - 'Failed to process contactpersoon object: ' . $e->getMessage(), + 'Failed to process contactpersoon object: ' . $e->getMessage(), [ 'exception' => $e, 'objectId' => $contactpersoonObject->getId() ?? 'unknown' @@ -1365,24 +1408,24 @@ public function processContactpersoon(object $contactpersoonObject, bool $isUpda * Sets a user account to inactive * * @param string $username The username to set as inactive - * + * * @return bool True if successful */ public function setUserInactive(string $username): bool { try { $user = $this->_userManager->get($username); - + if ($user) { $user->setEnabled(false); - + $this->_logger->info( 'Set user account to inactive', [ 'username' => $username ] ); - + return true; } else { $this->_logger->warning( @@ -1391,10 +1434,10 @@ public function setUserInactive(string $username): bool 'username' => $username ] ); - + return false; } - + } catch (\Exception $e) { $this->_logger->error( 'Failed to set user inactive: ' . $e->getMessage(), @@ -1403,7 +1446,7 @@ public function setUserInactive(string $username): bool 'exception' => $e ] ); - + return false; } } @@ -1412,24 +1455,24 @@ public function setUserInactive(string $username): bool * Sets a user account to active * * @param string $username The username to set as active - * + * * @return bool True if successful */ public function setUserActive(string $username): bool { try { $user = $this->_userManager->get($username); - + if ($user) { $user->setEnabled(true); - + $this->_logger->info( 'Set user account to active', [ 'username' => $username ] ); - + return true; } else { $this->_logger->warning( @@ -1438,10 +1481,10 @@ public function setUserActive(string $username): bool 'username' => $username ] ); - + return false; } - + } catch (\Exception $e) { $this->_logger->error( 'Failed to set user active: ' . $e->getMessage(), @@ -1450,7 +1493,7 @@ public function setUserActive(string $username): bool 'exception' => $e ] ); - + return false; } } @@ -1458,9 +1501,9 @@ public function setUserActive(string $username): bool /** * Handles contactpersoon updates, particularly role changes * - * @param object $contactpersoonObject The updated contactpersoon object + * @param object $contactpersoonObject The updated contactpersoon object * @param object $oldContactpersoonObject The previous contactpersoon object - * + * * @return void */ public function handleContactpersoonUpdate(object $contactpersoonObject, object $oldContactpersoonObject): void @@ -1472,14 +1515,14 @@ public function handleContactpersoonUpdate(object $contactpersoonObject, object // Process the updated contactpersoon $this->processContactpersoon($contactpersoonObject); - + // Check for role changes and update groups accordingly $newData = $contactpersoonObject->getObject(); $oldData = $oldContactpersoonObject->getObject(); - + $newRoles = $newData['roles'] ?? []; $oldRoles = $oldData['roles'] ?? []; - + // Ensure both are arrays if (!is_array($newRoles)) { $newRoles = [$newRoles]; @@ -1487,7 +1530,7 @@ public function handleContactpersoonUpdate(object $contactpersoonObject, object if (!is_array($oldRoles)) { $oldRoles = [$oldRoles]; } - + // Check if roles have changed if ($newRoles !== $oldRoles) { $username = $newData['username'] ?? ''; @@ -1503,7 +1546,7 @@ public function handleContactpersoonUpdate(object $contactpersoonObject, object 'newRoles' => $newRoles ] ); - + // Update user groups based on role changes $this->updateUserGroupsFromRoles($user, $newRoles, $oldRoles); } @@ -1524,9 +1567,9 @@ public function handleContactpersoonUpdate(object $contactpersoonObject, object /** * Sends account suspension notification email * - * @param \OCP\IUser $user The suspended user - * @param array $objectData The contact person data - * + * @param \OCP\IUser $user The suspended user + * @param array $objectData The contact person data + * * @return void */ private function sendAccountSuspensionEmail(\OCP\IUser $user, array $objectData): void @@ -1536,17 +1579,17 @@ private function sendAccountSuspensionEmail(\OCP\IUser $user, array $objectData) 'username' => $user->getUID(), 'email' => $user->getEMailAddress() ]); - - // For now, we'll use a simple log message as the PhpEmailService + + // For now, we'll use a simple log message as the PhpEmailService // doesn't have a specific suspension email method yet // This can be extended later if needed - + $this->_logger->info('Account suspension email would be sent here', [ 'username' => $user->getUID(), 'email' => $user->getEMailAddress(), 'displayName' => $user->getDisplayName() ]); - + } catch (\Exception $e) { $this->_logger->error('Exception sending account suspension email: ' . $e->getMessage(), [ 'username' => $user->getUID(), @@ -1560,7 +1603,7 @@ private function sendAccountSuspensionEmail(\OCP\IUser $user, array $objectData) * Checks if a contactpersoon username is in the organization's users list * * @param object $contactpersoonObject The contactpersoon object - * + * * @return bool True if the user should be added to the organization */ public function shouldAddContactpersoonToOrganization(object $contactpersoonObject): bool @@ -1591,10 +1634,10 @@ public function shouldAddContactpersoonToOrganization(object $contactpersoonObje try { $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisatieSchemaId); $organizationData = $organizationObject->getObject(); - + // Check if the username is already in the organization's users $organizationUsers = $organizationData['users'] ?? []; - + if (is_array($organizationUsers) && !in_array($username, $organizationUsers)) { $this->_logger->info('ContactPersonHandler: Contactpersoon should be added to organization', [ 'username' => $username, @@ -1631,7 +1674,7 @@ public function shouldAddContactpersoonToOrganization(object $contactpersoonObje * Adds a contactpersoon username to the organization's users list * * @param object $contactpersoonObject The contactpersoon object - * + * * @return bool True if the user was successfully added */ public function addContactpersoonToOrganization(object $contactpersoonObject): bool @@ -1668,17 +1711,17 @@ public function addContactpersoonToOrganization(object $contactpersoonObject): b try { $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisatieSchemaId); $organizationData = $organizationObject->getObject(); - + // Add the username to the organization's users list $organizationUsers = $organizationData['users'] ?? []; if (!is_array($organizationUsers)) { $organizationUsers = []; } - + if (!in_array($username, $organizationUsers)) { $organizationUsers[] = $username; $organizationData['users'] = $organizationUsers; - + // Update the organization object $updatedOrganization = $objectService->saveObject( $organizationData, @@ -1730,7 +1773,7 @@ public function addContactpersoonToOrganization(object $contactpersoonObject): b * Ensures contactpersoon is added to organization after user creation/update * * @param object $contactpersoonObject The contactpersoon object - * + * * @return void */ public function ensureContactpersoonInOrganization(object $contactpersoonObject): void @@ -1744,7 +1787,7 @@ public function ensureContactpersoonInOrganization(object $contactpersoonObject) if ($this->shouldAddContactpersoonToOrganization($contactpersoonObject)) { // Add user to organization $result = $this->addContactpersoonToOrganization($contactpersoonObject); - + if ($result) { $this->_logger->info('ContactPersonHandler: Successfully ensured contactpersoon in organization', [ 'objectId' => $contactpersoonObject->getId() @@ -1773,21 +1816,21 @@ public function ensureContactpersoonInOrganization(object $contactpersoonObject) ); } } - + /** * Adds a user to the organization entity (OpenRegister entity, not object) * * @param object $contactpersoonObject The contactpersoon object * @param string $username The username to add - * + * * @return void */ - private function addUserToOrganizationEntity(object $contactpersoonObject, string $username): void + public function addUserToOrganizationEntity(object $contactpersoonObject, string $username): void { try { $objectData = $contactpersoonObject->getObject(); $organizationUuid = $objectData['organisation'] ?? $objectData['organisatie'] ?? ''; - + if (empty($organizationUuid)) { $this->_logger->warning('ContactPersonHandler: No organization reference found for contact person', [ 'objectId' => $contactpersoonObject->getId(), @@ -1795,24 +1838,24 @@ private function addUserToOrganizationEntity(object $contactpersoonObject, strin ]); return; } - + $this->_logger->info('ContactPersonHandler: Adding user to organization entity', [ 'objectId' => $contactpersoonObject->getId(), 'username' => $username, 'organizationUuid' => $organizationUuid ]); - + try { $organisationMapper = $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper'); $organisation = $organisationMapper->findByUuid($organizationUuid); - + if ($organisation) { $currentUsers = $organisation->getUsers() ?? []; if (!in_array($username, $currentUsers)) { $currentUsers[] = $username; $organisation->setUsers($currentUsers); $organisationMapper->save($organisation); - + $this->_logger->info('ContactPersonHandler: Successfully added user to organization entity', [ 'objectId' => $contactpersoonObject->getId(), 'username' => $username, @@ -1850,4 +1893,4 @@ private function addUserToOrganizationEntity(object $contactpersoonObject, strin } } -} \ No newline at end of file +} diff --git a/lib/Service/SoftwareCatalogue/GroupHandler.php b/lib/Service/SoftwareCatalogue/GroupHandler.php index b4bfaf14..197620de 100644 --- a/lib/Service/SoftwareCatalogue/GroupHandler.php +++ b/lib/Service/SoftwareCatalogue/GroupHandler.php @@ -306,8 +306,9 @@ public function updateRoleBasedGroups(IUser $user, array $objectData): void ] ); } elseif (!$hasRole && $inGroup) { - // Remove user from group (except for special groups) - if (!in_array($groupName, ['software-catalog-users', 'ambtenaar'])) { + // Remove user from group (except for system groups) + // Note: Removed 'ambtenaar' from protected groups since it's no longer automatically assigned + if (!in_array($groupName, ['software-catalog-users'])) { $group->removeUser($user); $this->_logger->info( 'Removed user from role-based group', @@ -428,19 +429,17 @@ public function updateGemeenteGroups(IUser $user, array $objectData): void $actualUuid = $orgData['id'] ?? $organizationUuid; $orgType = strtolower($orgData['type'] ?? $orgData['soort'] ?? ''); + // Note: Removed automatic assignment of 'ambtenaar' group for gemeente organizations + // The 'ambtenaar' group can still be created if needed, but users are not automatically assigned if ($orgType === 'gemeente') { - $ambtenaarGroup = $this->createGroupIfNotExists('ambtenaar'); - - if ($ambtenaarGroup && !$ambtenaarGroup->inGroup($user)) { - $ambtenaarGroup->addUser($user); - $this->_logger->info( - 'Added user to ambtenaar group (gemeente)', - [ - 'username' => $user->getUID(), - 'organizationUuid' => $actualUuid - ] - ); - } + $this->_logger->debug( + 'User from gemeente organization (no automatic ambtenaar group assignment)', + [ + 'username' => $user->getUID(), + 'organizationUuid' => $actualUuid, + 'organizationType' => $orgType + ] + ); } } } catch (\Exception $e) { diff --git a/lib/Service/SymfonyEmailService.php b/lib/Service/SymfonyEmailService.php index 83e94095..20012d78 100644 --- a/lib/Service/SymfonyEmailService.php +++ b/lib/Service/SymfonyEmailService.php @@ -16,11 +16,11 @@ /** * Symfony Mailer-based email service for sending notification emails - * + * * This service handles sending various types of notification emails * using Symfony Mailer with configurable transports including SMTP, * SendGrid, Mailgun, and other providers. - * + * * @category Service * @package OCA\SoftwareCatalog\Service * @author Conduction b.v. @@ -154,28 +154,25 @@ class SymfonyEmailService * * @var string Contact welcome email template */ - private const CONTACT_WELCOME_TEMPLATE = ' + private const CONTACT_ADDED_TEMPLATE = ' - Welkom bij de Software Catalogus + Toegevoegd aan organisatie

Welkom {{ user.name }}!

Beste {{ user.name }},

-

U bent toegevoegd als contactpersoon in de Software Catalogus.

-

Er is automatisch een gebruikersaccount voor u aangemaakt waarmee u kunt inloggen op het platform.

+

Uw gebruikersnaam is succesvol toegevoegd aan {{ organization.name }}.

U kunt nu:

  • Inloggen op het platform
  • -
  • Software informatie beheren
  • +
  • Software beheren voor uw organisatie
  • +
  • Deelnemen aan de open data gemeenschap
  • Samenwerken met andere organisaties

Login gegevens:

-
    -
  • E-mailadres: {{ user.email }}
  • -
  • Wachtwoord: U ontvangt een apart e-mailadres met instructies voor het instellen van uw wachtwoord
  • -
+

We hebben uw bestaande account gekoppeld aan een nieuwe organisatie. Uw inloggegevens zijn hetzelfde, maar u kunt nu uw organisatie wisselen tussen uw organisaties.

Heeft u vragen? Neem dan contact met ons op via info@conduction.nl

Met vriendelijke groet,
Het Software Catalogus Team

@@ -268,7 +265,7 @@ private function createTransport(): TransportInterface { $emailSettings = $this->settingsService->getEmailSettings(); $transportType = $emailSettings['transportType'] ?? 'smtp'; - + try { switch ($transportType) { case 'smtp': @@ -359,7 +356,7 @@ private function createMailgunTransport(array $settings): TransportInterface { $apiKey = $settings['mailgunApiKey'] ?? ''; $domain = $settings['mailgunDomain'] ?? ''; - + if (empty($apiKey) || empty($domain)) { throw new \InvalidArgumentException('Mailgun API key and domain are required'); } @@ -398,7 +395,7 @@ private function createSesTransport(array $settings): TransportInterface $accessKey = $settings['sesAccessKey'] ?? ''; $secretKey = $settings['sesSecretKey'] ?? ''; $region = $settings['sesRegion'] ?? 'us-east-1'; - + if (empty($accessKey) || empty($secretKey)) { throw new \InvalidArgumentException('Amazon SES access key and secret key are required'); } @@ -421,7 +418,7 @@ private function createMailjetTransport(array $settings): TransportInterface { $apiKey = $settings['mailjetApiKey'] ?? ''; $secretKey = $settings['mailjetSecretKey'] ?? ''; - + if (empty($apiKey) || empty($secretKey)) { throw new \InvalidArgumentException('Mailjet API key and secret key are required'); } @@ -451,11 +448,12 @@ public function sendOrganizationRegistrationEmail(array $organization): bool 'hasTemplates' => $configStatus['hasTemplates'], 'organizationName' => $organization['naam'] ?? 'Unknown' ]); + return false; } - + $emailSettings = $this->settingsService->getEmailSettings(); - + // Check if organization registration emails are enabled if (!$emailSettings['organizationRegistrationEnabled']) { $this->logger->info('OrganizationRegistrationEmail: Organization registration emails disabled', [ @@ -464,8 +462,9 @@ public function sendOrganizationRegistrationEmail(array $organization): bool return false; } + $organizationName = $organization['naam'] ?? $organization['name'] ?? 'Onbekende Organisatie'; - + // Determine recipient email $recipientEmail = $this->getRecipientEmail($organization); if (!$recipientEmail) { @@ -500,14 +499,14 @@ public function sendOrganizationRegistrationEmail(array $organization): bool 'organization_registration', $templateData ); - + if ($success) { $this->logger->info('OrganizationRegistrationEmail: Successfully sent registration email', [ 'organizationName' => $organizationName, 'recipientEmail' => $recipientEmail ]); } - + return $success; } catch (\Exception $e) { $this->logger->error('OrganizationRegistrationEmail: Failed to send registration email', [ @@ -539,9 +538,9 @@ public function sendOrganizationActivationEmail(array $organization): bool ]); return false; } - + $emailSettings = $this->settingsService->getEmailSettings(); - + // Check if organization activation emails are enabled if (!$emailSettings['organizationActivationEnabled']) { $this->logger->info('OrganizationActivationEmail: Organization activation emails disabled', [ @@ -551,7 +550,7 @@ public function sendOrganizationActivationEmail(array $organization): bool } $organizationName = $organization['naam'] ?? $organization['name'] ?? 'Onbekende Organisatie'; - + // Determine recipient email $recipientEmail = $this->getRecipientEmail($organization); if (!$recipientEmail) { @@ -586,14 +585,14 @@ public function sendOrganizationActivationEmail(array $organization): bool 'organization_activation', $templateData ); - + if ($success) { $this->logger->info('OrganizationActivationEmail: Successfully sent activation email', [ 'organizationName' => $organizationName, 'recipientEmail' => $recipientEmail ]); } - + return $success; } catch (\Exception $e) { $this->logger->error('OrganizationActivationEmail: Failed to send activation email', [ @@ -626,9 +625,9 @@ public function sendUserCreationEmail(array $user, array $organization = []): bo ]); return false; } - + $emailSettings = $this->settingsService->getEmailSettings(); - + // Check if user creation emails are enabled if (!$emailSettings['userCreationEnabled']) { $this->logger->info('UserCreationEmail: User creation emails disabled', [ @@ -640,7 +639,7 @@ public function sendUserCreationEmail(array $user, array $organization = []): bo $userEmail = $user['email'] ?? ''; $userName = $user['naam'] ?? $user['name'] ?? ($user['voornaam'] ?? '') . ' ' . ($user['achternaam'] ?? ''); $userName = trim($userName); - + if (empty($userEmail)) { $this->logger->warning('UserCreationEmail: Cannot send without email address', [ 'user' => $user, @@ -676,14 +675,14 @@ public function sendUserCreationEmail(array $user, array $organization = []): bo 'user_creation', $templateData ); - + if ($success) { $this->logger->info('UserCreationEmail: Successfully sent user creation email', [ 'userName' => $userName, 'userEmail' => $userEmail ]); } - + return $success; } catch (\Exception $e) { $this->logger->error('UserCreationEmail: Failed to send user creation email', [ @@ -695,6 +694,96 @@ public function sendUserCreationEmail(array $user, array $organization = []): bo } } + /** + * Sends a user creation email + * + * @param array $user The user data + * @param array $organization The organization data (optional) + * @return bool True if email was sent successfully, false otherwise + * @throws \Exception If email sending fails + */ + public function sendUserUpdateEmail(array $user, array $organization = []): bool + { + // Check if email system is fully configured + $configStatus = $this->isEmailSystemConfigured(); + if (!$configStatus['configured']) { + $this->logger->info('UserCreationEmail: Email system not configured, skipping', [ + 'reason' => $configStatus['reason'], + 'hasCredentials' => $configStatus['hasCredentials'], + 'hasTemplates' => $configStatus['hasTemplates'], + 'userEmail' => $user['email'] ?? 'Unknown' + ]); + return false; + } + + $emailSettings = $this->settingsService->getEmailSettings(); + + // Check if user creation emails are enabled + if (!$emailSettings['userOrganisationEnabled']) { + $this->logger->info('UserOrganisationEmail: User creation emails disabled', [ + 'userEmail' => $user['email'] ?? 'Unknown' + ]); + return false; + } + + $userEmail = $user['email'] ?? ''; + $userName = $user['naam'] ?? $user['name'] ?? ($user['voornaam'] ?? '') . ' ' . ($user['achternaam'] ?? ''); + $userName = trim($userName); + + if (empty($userEmail)) { + $this->logger->warning('UserCreationEmail: Cannot send without email address', [ + 'user' => $user, + 'userName' => $userName + ]); + return false; + } + + $this->logger->info('UserCreationEmail: Sending user creation email', [ + 'userName' => $userName, + 'userEmail' => $userEmail, + 'organizationName' => $organization['naam'] ?? $organization['name'] ?? 'Software Catalogus', + 'transportType' => $configStatus['transportType'] + ]); + + // Prepare template data + $templateData = [ + 'user' => [ + 'name' => $userName ?: 'Gebruiker', + 'email' => $userEmail, + 'functie' => $user['functie'] ?? '', + ], + 'organization' => [ + 'name' => $organization['naam'] ?? $organization['name'] ?? 'Software Catalogus', + ] + ]; + + try { + $success = $this->sendTemplatedEmail( + $userEmail, + $userName ?: 'Gebruiker', + 'Welkom bij de Software Catalogus - Account toegevoegd aan organisatie', + 'user_organisation', + $templateData + ); + + if ($success) { + $this->logger->info('UserCreationEmail: Successfully sent user organisation email', [ + 'userName' => $userName, + 'userEmail' => $userEmail + ]); + } + + return $success; + } catch (\Exception $e) { + $this->logger->error('UserCreationEmail: Failed to send user organisation email', [ + 'userName' => $userName, + 'userEmail' => $userEmail, + 'error' => $e->getMessage() + ]); + return false; + } + } + /** * Sends a user password email * @@ -717,9 +806,9 @@ public function sendUserPasswordEmail(array $user, string $password, array $orga ]); return false; } - + $emailSettings = $this->settingsService->getEmailSettings(); - + // Check if user password emails are enabled if (!$emailSettings['userPasswordEnabled']) { $this->logger->info('UserPasswordEmail: User password emails disabled', [ @@ -731,7 +820,7 @@ public function sendUserPasswordEmail(array $user, string $password, array $orga $userEmail = $user['email'] ?? ''; $userName = $user['naam'] ?? $user['name'] ?? ($user['voornaam'] ?? '') . ' ' . ($user['achternaam'] ?? ''); $userName = trim($userName); - + if (empty($userEmail)) { $this->logger->warning('UserPasswordEmail: Cannot send without email address', [ 'user' => $user, @@ -768,14 +857,14 @@ public function sendUserPasswordEmail(array $user, string $password, array $orga 'user_password', $templateData ); - + if ($success) { $this->logger->info('UserPasswordEmail: Successfully sent user password email', [ 'userName' => $userName, 'userEmail' => $userEmail ]); } - + return $success; } catch (\Exception $e) { $this->logger->error('UserPasswordEmail: Failed to send user password email', [ @@ -845,6 +934,7 @@ private function getDefaultTemplate(string $templateName): string 'organization_activation' => self::ORGANIZATION_ACTIVATION_TEMPLATE, 'user_creation' => self::GEBRUIKER_WELCOME_TEMPLATE, 'user_password' => self::USER_PASSWORD_TEMPLATE, + 'user_organisation' => self::CONTACT_ADDED_TEMPLATE, default => self::ORGANIZATION_WELCOME_TEMPLATE, }; } @@ -859,13 +949,13 @@ private function getDefaultTemplate(string $templateName): string private function processTemplate(string $template, array $templateData): string { $processed = $template; - + // Replace organization variables if (isset($templateData['organization'])) { $org = $templateData['organization']; $processed = str_replace('{{ organization.name }}', $org['name'] ?? '', $processed); } - + // Replace user variables if (isset($templateData['user'])) { $user = $templateData['user']; @@ -873,7 +963,7 @@ private function processTemplate(string $template, array $templateData): string $processed = str_replace('{{ user.email }}', $user['email'] ?? '', $processed); $processed = str_replace('{{ user.password }}', $user['password'] ?? '', $processed); } - + // Replace contact variables (backward compatibility) if (isset($templateData['user'])) { $user = $templateData['user']; @@ -899,13 +989,13 @@ private function getRecipientEmail(array $data): ?string } // Try to get email from various fields - $email = $data['email'] ?? null; - + $email = $data['e-mailadres'] ?? null; + // If no direct email, try to get from contactpersonen if (!$email && isset($data['contactpersonen']) && is_array($data['contactpersonen'])) { foreach ($data['contactpersonen'] as $contact) { - if (is_array($contact) && !empty($contact['email'])) { - $email = $contact['email']; + if (is_array($contact) && !empty($contact['e-mailadres'])) { + $email = $contact['e-mailadres']; break; } } @@ -923,7 +1013,7 @@ private function getTestReceiverOverride(): ?string { $emailSettings = $this->settingsService->getEmailSettings(); $override = $emailSettings['testReceiverOverride'] ?? ''; - + return !empty($override) && $this->validateEmail($override) ? $override : null; } @@ -966,7 +1056,7 @@ private function sendEmail( 'sender' => $senderEmail, 'transport' => $emailSettings['transportType'] ?? 'smtp' ]); - + return true; } catch (\Exception $e) { @@ -1021,11 +1111,11 @@ public function getSenderName(): string public function getEmailSettings(): array { $settings = $this->settingsService->getEmailSettings(); - + // Add transport information $settings['availableTransports'] = self::TRANSPORT_TYPES; $settings['transportType'] = $settings['transportType'] ?? 'smtp'; - + return $settings; } @@ -1100,7 +1190,7 @@ public function sendTestEmail(string $testEmail): bool $subject = 'Software Catalogus - Test Email (Symfony Mailer)'; $emailSettings = $this->getEmailSettings(); $transportType = $emailSettings['transportType'] ?? 'smtp'; - + $htmlBody = ' @@ -1211,7 +1301,7 @@ public function setUserPasswordEnabled(bool $enabled): void public function isEmailSystemConfigured(): array { $emailSettings = $this->settingsService->getEmailSettings(); - + // Check if emails are enabled if (!($emailSettings['enabled'] ?? false)) { return [ @@ -1221,15 +1311,15 @@ public function isEmailSystemConfigured(): array 'hasTemplates' => false ]; } - + // Check transport credentials $hasCredentials = $this->hasValidTransportCredentials($emailSettings); - + // Check templates $hasTemplates = $this->hasValidTemplates($emailSettings); - + $configured = $hasCredentials && $hasTemplates; - + return [ 'configured' => $configured, 'reason' => $configured ? 'Email system fully configured' : $this->getConfigurationIssues($hasCredentials, $hasTemplates), @@ -1238,7 +1328,7 @@ public function isEmailSystemConfigured(): array 'transportType' => $emailSettings['transportType'] ?? 'smtp' ]; } - + /** * Checks if the current transport has valid credentials * @@ -1248,7 +1338,7 @@ public function isEmailSystemConfigured(): array private function hasValidTransportCredentials(array $emailSettings): bool { $transportType = $emailSettings['transportType'] ?? 'smtp'; - + switch ($transportType) { case 'mailjet': return !empty($emailSettings['mailjetApiKey']) && !empty($emailSettings['mailjetSecretKey']); @@ -1270,7 +1360,7 @@ private function hasValidTransportCredentials(array $emailSettings): bool return false; } } - + /** * Checks if required templates are configured * @@ -1281,7 +1371,7 @@ private function hasValidTemplates(array $emailSettings): bool { $templates = $emailSettings['templates'] ?? []; $requiredTemplates = ['organization_registration', 'organization_activation', 'user_creation', 'user_password']; - + foreach ($requiredTemplates as $templateName) { $template = $templates[$templateName] ?? ''; // Template is valid if it's not empty or if we have a default template @@ -1289,10 +1379,10 @@ private function hasValidTemplates(array $emailSettings): bool return false; } } - + return true; } - + /** * Gets configuration issues description * @@ -1303,15 +1393,15 @@ private function hasValidTemplates(array $emailSettings): bool private function getConfigurationIssues(bool $hasCredentials, bool $hasTemplates): string { $issues = []; - + if (!$hasCredentials) { $issues[] = 'missing transport credentials'; } - + if (!$hasTemplates) { $issues[] = 'missing email templates'; } - + return 'Configuration incomplete: ' . implode(', ', $issues); } -} \ No newline at end of file +} diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php new file mode 100644 index 00000000..a74ccd8e --- /dev/null +++ b/lib/Service/ViewService.php @@ -0,0 +1,1228 @@ +logger->info('Getting all views', [ + 'options' => $options + ]); + + try { + // Get all view objects from OpenRegister + $views = $this->getViewsFromRegister(); + + // Transform views to include critical API fields + $views = $this->transformViews($views); + + // Apply enrichments based on options + if (!empty($options)) { + $views = $this->enrichViews($views, $options); + } + + // Apply module-to-viewNode expansion if modules are enabled + if (isset($options['include_modules']) && $options['include_modules']) { + $views = $this->expandModulesToViewNodes($views, $options); + } + + return [ + 'success' => true, + 'views' => $views, + 'count' => count($views), + 'enrichments_applied' => $this->getAppliedEnrichments($options) + ]; + + } catch (\Exception $e) { + $this->logger->error('Failed to get all views', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + 'views' => [], + 'count' => 0 + ]; + } + } + + /** + * Get a specific view by ID + * + * @param string $viewId The view identifier + * @param array $options Query options including enrichment flags + * @return array View object with optional enrichments or error response + */ + public function getView(string $viewId, array $options = []): array + { + $this->logger->info('Getting specific view', [ + 'view_id' => $viewId, + 'options' => $options + ]); + + try { + // Get specific view from OpenRegister + $view = $this->getViewFromRegister($viewId); + + if (!$view) { + return [ + 'success' => false, + 'error' => "View with ID '$viewId' not found", + 'view' => null + ]; + } + + // Transform view to include critical API fields + $view = $this->transformView($view); + + // Apply enrichments based on options + if (!empty($options)) { + $view = $this->enrichView($view, $options); + + // Apply module-to-viewNode expansion if modules are enabled + if (isset($options['include_modules']) && $options['include_modules']) { + $views = $this->expandModulesToViewNodes([$view], $options); + $view = $views[0] ?? $view; // Get the expanded view back + } + } + + return [ + 'success' => true, + 'view' => $view, + 'enrichments_applied' => $this->getAppliedEnrichments($options) + ]; + + } catch (\Exception $e) { + $this->logger->error('Failed to get view', [ + 'view_id' => $viewId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + 'view' => null + ]; + } + } + + /** + * Get views from OpenRegister system + * + * @return array Array of view objects + */ + private function getViewsFromRegister(): array + { + $objectService = $this->getObjectService(); + if (!$objectService) { + throw new \RuntimeException('OpenRegister ObjectService not available'); + } + + // Get AMEF configuration for view schema + $amefConfig = $this->settingsService->getAmefConfig(); + $registerId = $amefConfig['register_id'] ?? $amefConfig['register'] ?? null; + $viewSchemaId = $amefConfig['view_schema'] ?? null; + + if (!$registerId || !$viewSchemaId) { + throw new \RuntimeException('AMEF configuration not found for views'); + } + + // Query for all view objects + $query = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $viewSchemaId + ], + 'section' => 'view' + ]; + + $views = $objectService->searchObjects($query); + + $this->logger->debug('Retrieved views from register', [ + 'register_id' => $registerId, + 'view_schema_id' => $viewSchemaId, + 'views_count' => count($views) + ]); + + return $views; + } + + /** + * Get a specific view from OpenRegister system + * + * @param string $viewId The view identifier + * @return array|null View object or null if not found + */ + private function getViewFromRegister(string $viewId): ?array + { + $objectService = $this->getObjectService(); + if (!$objectService) { + throw new \RuntimeException('OpenRegister ObjectService not available'); + } + + // Get AMEF configuration for view schema + $amefConfig = $this->settingsService->getAmefConfig(); + $registerId = $amefConfig['register_id'] ?? $amefConfig['register'] ?? null; + $viewSchemaId = $amefConfig['view_schema'] ?? null; + + if (!$registerId || !$viewSchemaId) { + throw new \RuntimeException('AMEF configuration not found for views'); + } + + try { + // Get specific view object by ID + $view = $objectService->getObject($registerId, $viewSchemaId, $viewId); + + $this->logger->debug('Retrieved specific view from register', [ + 'view_id' => $viewId, + 'register_id' => $registerId, + 'view_schema_id' => $viewSchemaId, + 'found' => $view !== null + ]); + + return $view; + + } catch (\Exception $e) { + $this->logger->warning('View not found in register', [ + 'view_id' => $viewId, + 'error' => $e->getMessage() + ]); + return null; + } + } + + /** + * Enrich multiple views with additional data + * + * @param array $views Array of view objects + * @param array $options Enrichment options + * @return array Array of enriched view objects + */ + private function enrichViews(array $views, array $options): array + { + $enrichedViews = []; + + foreach ($views as $view) { + $enrichedViews[] = $this->enrichView($view, $options); + } + + return $enrichedViews; + } + + /** + * Enrich a single view with additional data + * + * @param array $view View object to enrich + * @param array $options Enrichment options + * @return array Enriched view object + */ + private function enrichView(array $view, array $options): array + { + $enrichedView = $view; + + // Enrich viewNodes if present + if (isset($view['viewNodes']) && is_array($view['viewNodes'])) { + $enrichedView['viewNodes'] = $this->enrichViewNodes($view['viewNodes'], $options); + } + + // TODO: Add view-level enrichments here if needed + + return $enrichedView; + } + + /** + * Enrich view nodes with additional data based on options + * + * @param array $viewNodes Array of view nodes + * @param array $options Enrichment options + * @return array Array of enriched view nodes + */ + private function enrichViewNodes(array $viewNodes, array $options): array + { + $enrichedNodes = []; + + // Get enrichment data upfront if requested + $productsData = []; + $modulesData = []; + $gebruikData = []; + $deelnamesGebruikData = []; + + if ($this->shouldIncludeProducts($options)) { + $productsData = $this->getProductsData(); + } + + if ($this->shouldIncludeModules($options)) { + $modulesData = $this->getModulesData(); + } + + if ($this->shouldIncludeGebruik($options)) { + $gebruikData = $this->getGebruikData($options); + } + + if ($this->shouldIncludeDeelnamesGebruik($options)) { + $deelnamesGebruikData = $this->getDeelnamesGebruikData(); + } + + foreach ($viewNodes as $node) { + $enrichedNode = $node; + + // Apply enrichments based on the node's modelNodeId (element reference) + $modelNodeId = $node['modelNodeId'] ?? null; + + if ($modelNodeId) { + if ($this->shouldIncludeProducts($options)) { + $enrichedNode['products'] = $this->getNodeProducts($modelNodeId, $productsData); + } + + if ($this->shouldIncludeModules($options)) { + $enrichedNode['modules'] = $this->getNodeModules($modelNodeId, $modulesData); + } + + if ($this->shouldIncludeGebruik($options)) { + $enrichedNode['gebruik'] = $this->getNodeGebruik($modelNodeId, $gebruikData); + } + + if ($this->shouldIncludeDeelnamesGebruik($options)) { + $enrichedNode['deelnamesGebruik'] = $this->getNodeDeelnamesGebruik($modelNodeId, $deelnamesGebruikData); + } + } + + $enrichedNodes[] = $enrichedNode; + } + + return $enrichedNodes; + } + + /** + * Check if products should be included in enrichment + * + * @param array $options Enrichment options + * @return bool True if products should be included + */ + private function shouldIncludeProducts(array $options): bool + { + return isset($options['include_products']) && $options['include_products'] === true; + } + + /** + * Check if modules should be included in enrichment + * + * @param array $options Enrichment options + * @return bool True if modules should be included + */ + private function shouldIncludeModules(array $options): bool + { + return isset($options['include_modules']) && $options['include_modules'] === true; + } + + /** + * Check if gebruik should be included in enrichment + * + * @param array $options Enrichment options + * @return bool True if gebruik should be included + */ + private function shouldIncludeGebruik(array $options): bool + { + return isset($options['include_gebruik']) && $options['include_gebruik'] === true; + } + + /** + * Check if deelnames gebruik should be included in enrichment + * + * @param array $options Enrichment options + * @return bool True if deelnames gebruik should be included + */ + private function shouldIncludeDeelnamesGebruik(array $options): bool + { + return isset($options['include_deelnames_gebruik']) && $options['include_deelnames_gebruik'] === true; + } + + /** + * Get applied enrichments list for response metadata + * + * @param array $options Enrichment options + * @return array List of applied enrichments + */ + private function getAppliedEnrichments(array $options): array + { + $enrichments = []; + + if ($this->shouldIncludeProducts($options)) { + $enrichments[] = 'products'; + } + + if ($this->shouldIncludeModules($options)) { + $enrichments[] = 'modules'; + } + + if ($this->shouldIncludeGebruik($options)) { + $enrichments[] = 'gebruik'; + } + + if ($this->shouldIncludeDeelnamesGebruik($options)) { + $enrichments[] = 'deelnames_gebruik'; + } + + return $enrichments; + } + + /** + * Get products data for enrichment (placeholder implementation) + * + * @return array Products data + */ + private function getProductsData(): array + { + // TODO: Implement actual products data retrieval + $this->logger->debug('Getting products data for enrichment'); + return []; + } + + /** + * Get current active organisation for filtering + * + * @return string|null Current organisation identifier + */ + private function getCurrentOrganisation(): ?string + { + $user = $this->userSession->getUser(); + if (!$user) { + return null; + } + + // TODO: Implement proper organisation determination logic + // This could be based on user groups, settings, or other mechanisms + // For now, return a placeholder that would need actual implementation + + $userId = $user->getUID(); + $this->logger->debug('Getting current organisation for user', [ + 'user_id' => $userId + ]); + + // Placeholder - needs actual implementation based on your organisation structure + return 'default'; // Replace with actual organisation logic + } + + /** + * Get modules data for enrichment based on elementRef linkage + * + * Modules are directly linked to nodes based on their elementRef. + * This method retrieves all module data from OpenRegister, filtered by current organisation. + * + * @return array Modules data indexed by elementRef + */ + private function getModulesData(): array + { + $this->logger->debug('Getting modules data for enrichment'); + + try { + $objectService = $this->getObjectService(); + if (!$objectService) { + return []; + } + + // Get current organisation for filtering + $currentOrg = $this->getCurrentOrganisation(); + + // Get configuration for modules register/schema + $amefConfig = $this->settingsService->getAmefConfig(); + $registerId = $amefConfig['register_id'] ?? null; + + // Modules could be in various schemas - check common ones + $moduleSchemas = [ + $amefConfig['module_schema'] ?? null, + $amefConfig['component_schema'] ?? null, + $amefConfig['element_schema'] ?? null + ]; + + $allModules = []; + + foreach ($moduleSchemas as $schemaId) { + if (!$schemaId) continue; + + try { + $query = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId + ] + ]; + + // Add organisation filter if current organisation is available + if ($currentOrg) { + $query['@self']['organisation'] = $currentOrg; + } + + $modules = $objectService->searchObjects($query); + + foreach ($modules as $module) { + // Additional check for organisation in metadata if not caught by query + if ($currentOrg && isset($module['@self']['organisation']) && $module['@self']['organisation'] !== $currentOrg) { + continue; + } + + // Index by elementRef or identifier for quick lookup + $elementRef = $module['elementRef'] ?? $module['identifier'] ?? null; + if ($elementRef) { + $allModules[$elementRef] = $module; + } + } + + $this->logger->debug('Retrieved modules from schema', [ + 'schema_id' => $schemaId, + 'modules_count' => count($modules) + ]); + + } catch (\Exception $e) { + $this->logger->warning('Failed to get modules from schema', [ + 'schema_id' => $schemaId, + 'error' => $e->getMessage() + ]); + } + } + + $this->logger->debug('Total modules retrieved for enrichment', [ + 'total_modules' => count($allModules) + ]); + + return $allModules; + + } catch (\Exception $e) { + $this->logger->error('Failed to get modules data', [ + 'error' => $e->getMessage() + ]); + return []; + } + } + + /** + * Get gebruik data for enrichment based on elementRef linkage + * + * Usage data is linked to nodes based on their elementRef. + * This method retrieves usage statistics from OpenRegister, filtered by organisation. + * Also handles deelnames logic when enabled. + * + * @param array $options Query options for deelnames handling + * @return array Gebruik data indexed by elementRef with extended modules + */ + private function getGebruikData(array $options = []): array + { + $this->logger->debug('Getting gebruik data for enrichment with options', ['options' => array_keys($options)]); + + try { + $objectService = $this->getObjectService(); + if (!$objectService) { + return []; + } + + // Get current organisation for filtering + $currentOrg = $this->getCurrentOrganisation(); + + // Get configuration for usage register/schema + $amefConfig = $this->settingsService->getAmefConfig(); + $registerId = $amefConfig['register_id'] ?? null; + + // Usage data could be in various schemas + $gebruikSchemas = [ + $amefConfig['gebruik_schema'] ?? null, + $amefConfig['usage_schema'] ?? null, + $amefConfig['statistics_schema'] ?? null + ]; + + $allGebruik = []; + + // STEP 1: Get regular gebruik data filtered by current organisation + foreach ($gebruikSchemas as $schemaId) { + if (!$schemaId) continue; + + try { + $query = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId + ] + ]; + + // Add organisation filter for regular gebruik + if ($currentOrg) { + $query['@self']['organisation'] = $currentOrg; + } + + $gebruikItems = $objectService->searchObjects($query); + $this->processGebruikItems($gebruikItems, $allGebruik, $currentOrg, 'regular'); + + $this->logger->debug('Retrieved regular gebruik from schema', [ + 'schema_id' => $schemaId, + 'gebruik_count' => count($gebruikItems), + 'organisation' => $currentOrg + ]); + + } catch (\Exception $e) { + $this->logger->warning('Failed to get regular gebruik from schema', [ + 'schema_id' => $schemaId, + 'error' => $e->getMessage() + ]); + } + } + + // STEP 2: If deelnames is enabled, get additional gebruik with RBAC off + if (isset($options['include_deelnames_gebruik']) && $options['include_deelnames_gebruik'] && $currentOrg) { + $this->logger->debug('Processing deelnames gebruik with RBAC disabled'); + + foreach ($gebruikSchemas as $schemaId) { + if (!$schemaId) continue; + + try { + // Search with RBAC disabled to find gebruik where current org is in deelnemers + $query = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId + ], + 'deelnemers' => $currentOrg // Current org in deelnemers field + ]; + + // Call with RBAC disabled + $deelnamesGebruikItems = $objectService->searchObjects($query, rbac: false); + $this->processGebruikItems($deelnamesGebruikItems, $allGebruik, $currentOrg, 'deelnames'); + + $this->logger->debug('Retrieved deelnames gebruik from schema', [ + 'schema_id' => $schemaId, + 'deelnames_gebruik_count' => count($deelnamesGebruikItems), + 'organisation_in_deelnemers' => $currentOrg + ]); + + } catch (\Exception $e) { + $this->logger->warning('Failed to get deelnames gebruik from schema', [ + 'schema_id' => $schemaId, + 'error' => $e->getMessage() + ]); + } + } + } + + // STEP 3: Extend gebruik with modules data + $allGebruik = $this->extendGebruikWithModules($allGebruik); + + $this->logger->debug('Total gebruik retrieved and processed', [ + 'total_element_refs_with_gebruik' => count($allGebruik), + 'current_organisation' => $currentOrg, + 'deelnames_enabled' => isset($options['include_deelnames_gebruik']) && $options['include_deelnames_gebruik'] + ]); + + return $allGebruik; + + } catch (\Exception $e) { + $this->logger->error('Failed to get gebruik data', [ + 'error' => $e->getMessage() + ]); + return []; + } + } + + /** + * Process gebruik items and group them by elementRef + * + * @param array $gebruikItems Array of gebruik objects + * @param array &$allGebruik Array to add items to (by reference) + * @param string $type Type identifier ('regular' or 'deelnames') + * @return void + */ + private function processGebruikItems(array $gebruikItems, array &$allGebruik, string $currentOrg, string $type): void + { + foreach ($gebruikItems as $gebruik) { + // Group by elementRef for quick lookup + $elementRef = $gebruik['elementRef'] ?? $gebruik['componentRef'] ?? $gebruik['moduleRef'] ?? null; + + if ($elementRef) { + if (!isset($allGebruik[$elementRef])) { + $allGebruik[$elementRef] = []; + } + + // Add type indicator and processing info + $gebruik['_type'] = $type; + $gebruik['_processed_for_org'] = $currentOrg; + + $allGebruik[$elementRef][] = $gebruik; + } + } + } + + /** + * Extend gebruik data with modules information + * + * @param array $allGebruik Gebruik data indexed by elementRef + * @return array Extended gebruik data with modules + */ + private function extendGebruikWithModules(array $allGebruik): array + { + $this->logger->debug('Extending gebruik data with modules information'); + + // Get modules data for extension + $modulesData = $this->getModulesData(); + + foreach ($allGebruik as $elementRef => &$gebruikList) { + // Check if we have a module for this elementRef + if (isset($modulesData[$elementRef])) { + $module = $modulesData[$elementRef]; + + // Add module data to each gebruik item for this elementRef + foreach ($gebruikList as &$gebruik) { + $gebruik['_linked_module'] = $module; + + $this->logger->debug('Linked module to gebruik', [ + 'element_ref' => $elementRef, + 'gebruik_id' => $gebruik['id'] ?? $gebruik['identifier'] ?? 'unknown', + 'module_id' => $module['id'] ?? $module['identifier'] ?? 'unknown', + 'module_name' => $module['name'] ?? 'unnamed' + ]); + } + unset($gebruik); // Clean up reference + } + } + unset($gebruikList); // Clean up reference + + return $allGebruik; + } + + /** + * Expand modules to viewNodes based on referentiecomponent relationships + * + * For each module that has referentiecomponent relationships, add new nodes + * to viewNodes with the module as parent. + * + * @param array $views Array of views to process + * @return array Views with expanded module nodes + */ + private function expandModulesToViewNodes(array $views): array + { + $this->logger->debug('Expanding modules to viewNodes'); + + // Get modules data for expansion + $modulesData = $this->getModulesData(); + + foreach ($views as &$view) { + if (!isset($view['viewNodes']) || !is_array($view['viewNodes'])) { + continue; + } + + $originalNodes = $view['viewNodes']; + $expandedNodes = $originalNodes; + + // Create a lookup of existing nodes by modelNodeId for quick parent matching + $nodesByModelId = []; + foreach ($originalNodes as $node) { + $modelNodeId = $node['modelNodeId'] ?? null; + if ($modelNodeId) { + $nodesByModelId[$modelNodeId] = $node; + } + } + + // Process each module for expansion + foreach ($modulesData as $elementRef => $module) { + $expandedNodes = $this->expandModuleToNodes($module, $expandedNodes, $nodesByModelId); + } + + // Update the view with expanded nodes + $view['viewNodes'] = $expandedNodes; + + $addedNodesCount = count($expandedNodes) - count($originalNodes); + if ($addedNodesCount > 0) { + $this->logger->debug('Added module nodes to view', [ + 'view_id' => $view['id'] ?? 'unknown', + 'original_nodes' => count($originalNodes), + 'expanded_nodes' => count($expandedNodes), + 'added_nodes' => $addedNodesCount + ]); + } + } + unset($view); // Clean up reference + + return $views; + } + + /** + * Expand a single module to nodes based on its referentiecomponent relationships + * + * @param array $module Module data + * @param array $existingNodes Current view nodes + * @param array $nodesByModelId Lookup of nodes by modelNodeId + * @return array Updated nodes array with module expansions + */ + private function expandModuleToNodes(array $module, array $existingNodes, array $nodesByModelId): array + { + $expandedNodes = $existingNodes; + + // Look for referentiecomponent relationships in the module + $referentieComponenten = $this->extractReferentieComponenten($module); + + foreach ($referentieComponenten as $referentieComponentId) { + // Find if there's an existing node for this referentiecomponent + $parentNode = $nodesByModelId[$referentieComponentId] ?? null; + + if ($parentNode) { + // Create a new node for this module as child of the referentiecomponent + $moduleNode = $this->createModuleNode($module, $parentNode, $referentieComponentId); + $expandedNodes[] = $moduleNode; + + $this->logger->debug('Created module node', [ + 'module_id' => $module['id'] ?? $module['identifier'] ?? 'unknown', + 'module_name' => $module['name'] ?? 'unnamed', + 'parent_node_id' => $parentNode['viewNodeId'] ?? 'unknown', + 'referentie_component_id' => $referentieComponentId + ]); + } + } + + return $expandedNodes; + } + + /** + * Extract referentiecomponent IDs from module data + * + * @param array $module Module data + * @return array Array of referentiecomponent identifiers + */ + private function extractReferentieComponenten(array $module): array + { + $referentieComponenten = []; + + // Look for referentiecomponent relationships in various possible locations + $possibleFields = [ + 'referentiecomponenten', + 'referentieComponenten', + 'relatedComponents', + 'components', + 'relationships' + ]; + + foreach ($possibleFields as $field) { + if (isset($module[$field])) { + $value = $module[$field]; + + if (is_array($value)) { + // Handle array of components + foreach ($value as $component) { + if (is_string($component)) { + $referentieComponenten[] = $component; + } elseif (is_array($component) && isset($component['id'])) { + $referentieComponenten[] = $component['id']; + } elseif (is_array($component) && isset($component['identifier'])) { + $referentieComponenten[] = $component['identifier']; + } + } + } elseif (is_string($value)) { + // Handle single component ID + $referentieComponenten[] = $value; + } + } + } + + return array_unique($referentieComponenten); + } + + /** + * Create a module node as child of a referentiecomponent node + * + * @param array $module Module data + * @param array $parentNode Parent referentiecomponent node + * @param string $referentieComponentId Referentiecomponent identifier + * @return array New module node + */ + private function createModuleNode(array $module, array $parentNode, string $referentieComponentId): array + { + $moduleId = $module['id'] ?? $module['identifier'] ?? uniqid('module_'); + $moduleName = $module['name'] ?? 'Unnamed Module'; + + // Position the module node relative to parent (slightly offset) + $parentX = $parentNode['x'] ?? 0; + $parentY = $parentNode['y'] ?? 0; + $parentWidth = $parentNode['width'] ?? 100; + + return [ + 'modelNodeId' => $moduleId, + 'viewNodeId' => 'module-' . $moduleId . '-' . uniqid(), + 'x' => $parentX + $parentWidth + 20, // Position to the right of parent + 'y' => $parentY, + 'width' => 150, + 'height' => 50, + 'parent' => $parentNode['viewNodeId'] ?? null, + 'name' => $moduleName, + 'type' => 'module', + 'color' => 'rgb(200, 255, 200)', // Light green for modules + 'borderColor' => 'rgb(0, 150, 0)', + 'font' => [ + 'name' => 'Segoe UI, Arial', + 'size' => 11, + 'style' => 'normal', + 'color' => 'rgb(0, 0, 0)' + ], + 'description' => $module['description'] ?? $module['summary'] ?? null, + 'elementRef' => $moduleId, + '_isModuleExpansion' => true, + '_parentReferentieComponent' => $referentieComponentId, + '_moduleData' => $module + ]; + } + + /** + * Get deelnames gebruik data for enrichment (placeholder implementation) + * + * @return array Deelnames gebruik data + */ + private function getDeelnamesGebruikData(): array + { + // TODO: Implement actual deelnames gebruik data retrieval + $this->logger->debug('Getting deelnames gebruik data for enrichment'); + return []; + } + + /** + * Get products for a specific node (placeholder implementation) + * + * @param string $modelNodeId The model node identifier + * @param array $productsData Products data to search in + * @return array Products related to the node + */ + private function getNodeProducts(string $modelNodeId, array $productsData): array + { + // TODO: Implement actual node products matching logic + $this->logger->debug('Getting products for node', ['model_node_id' => $modelNodeId]); + return []; + } + + /** + * Get modules for a specific node based on elementRef linkage + * + * @param string $modelNodeId The model node identifier (elementRef) + * @param array $modulesData Modules data indexed by elementRef + * @return array Modules related to the node + */ + private function getNodeModules(string $modelNodeId, array $modulesData): array + { + $this->logger->debug('Getting modules for node', ['model_node_id' => $modelNodeId]); + + // Direct lookup by elementRef + if (isset($modulesData[$modelNodeId])) { + $module = $modulesData[$modelNodeId]; + + $this->logger->debug('Found module for node', [ + 'model_node_id' => $modelNodeId, + 'module_id' => $module['id'] ?? $module['identifier'] ?? 'unknown', + 'module_name' => $module['name'] ?? 'unnamed' + ]); + + return [$module]; // Return as array for consistency + } + + // No module found for this node + return []; + } + + /** + * Get gebruik for a specific node based on elementRef linkage + * + * @param string $modelNodeId The model node identifier (elementRef) + * @param array $gebruikData Gebruik data indexed by elementRef + * @return array Gebruik related to the node + */ + private function getNodeGebruik(string $modelNodeId, array $gebruikData): array + { + $this->logger->debug('Getting gebruik for node', ['model_node_id' => $modelNodeId]); + + // Direct lookup by elementRef + if (isset($gebruikData[$modelNodeId])) { + $gebruikList = $gebruikData[$modelNodeId]; + + $this->logger->debug('Found gebruik for node', [ + 'model_node_id' => $modelNodeId, + 'gebruik_count' => count($gebruikList) + ]); + + return $gebruikList; + } + + // No usage data found for this node + return []; + } + + /** + * Get deelnames gebruik for a specific node (placeholder implementation) + * + * @param string $modelNodeId The model node identifier + * @param array $deelnamesGebruikData Deelnames gebruik data to search in + * @return array Deelnames gebruik related to the node + */ + private function getNodeDeelnamesGebruik(string $modelNodeId, array $deelnamesGebruikData): array + { + // TODO: Implement actual node deelnames gebruik matching logic + $this->logger->debug('Getting deelnames gebruik for node', ['model_node_id' => $modelNodeId]); + return []; + } + + /** + * Get ObjectService from container + * + * @return ObjectService|null ObjectService instance or null if not available + */ + private function getObjectService(): ?ObjectService + { + if (!$this->appManager->isInstalled('openregister')) { + return null; + } + + try { + return $this->container->get(ObjectService::class); + } catch (\Exception $e) { + $this->logger->warning('Failed to get ObjectService', [ + 'error' => $e->getMessage() + ]); + return null; + } + } + + /** + * Transform multiple views to include critical API fields + * + * @param array $views Array of view objects + * @return array Array of transformed view objects + */ + private function transformViews(array $views): array + { + $transformedViews = []; + + foreach ($views as $view) { + $transformedViews[] = $this->transformView($view); + } + + return $transformedViews; + } + + /** + * Transform a single view to include critical API fields + * + * @param array $view View object to transform + * @return array Transformed view object with critical API fields + */ + private function transformView(array $view): array + { + $transformedView = $view; + + // Add view documentation from XML if available + if (isset($view['xml']['documentation'])) { + $transformedView['documentation'] = $view['xml']['documentation']; + } + + // Transform properties from XML to required API format + if (isset($view['xml']['properties']['property']) && is_array($view['xml']['properties']['property'])) { + $transformedView['properties'] = $this->transformViewProperties($view['xml']['properties']['property']); + } + + // Transform viewNodes to include critical fields + if (isset($view['xml']['viewNodes']) && is_array($view['xml']['viewNodes'])) { + $transformedView['viewNodes'] = $this->transformViewNodes($view['xml']['viewNodes']); + } + + // Transform viewRelationships to include critical fields + if (isset($view['xml']['viewRelationships']) && is_array($view['xml']['viewRelationships'])) { + $transformedView['viewRelationships'] = $this->transformViewRelationships($view['xml']['viewRelationships']); + } + + return $transformedView; + } + + /** + * Transform view properties to required API format + * + * @param array $properties Array of property objects from XML + * @return array Array of transformed properties + */ + private function transformViewProperties(array $properties): array + { + $transformedProperties = []; + + foreach ($properties as $property) { + $transformedProperty = [ + 'propertyDefinitionRef' => $property['_propertyDefinitionRef'] ?? $property['___propertyDefinitionRef'] ?? null, + 'value' => $property['value']['_value'] ?? null + ]; + + if ($transformedProperty['propertyDefinitionRef'] && $transformedProperty['value']) { + $transformedProperties[] = $transformedProperty; + } + } + + return $transformedProperties; + } + + /** + * Transform viewNodes to include critical API fields + * + * @param array $viewNodes Array of viewNode objects from XML + * @return array Array of transformed viewNodes + */ + private function transformViewNodes(array $viewNodes): array + { + $transformedNodes = []; + + foreach ($viewNodes as $node) { + $transformedNode = $node; // Keep all existing fields + + // Add critical API fields + $transformedNode['identifier'] = $node['viewNodeId'] ?? null; + $transformedNode['documentation'] = $node['description'] ?? null; + + // Add position with width and height + $transformedNode['position'] = [ + 'x' => $node['x'] ?? null, + 'y' => $node['y'] ?? null, + 'w' => $node['width'] ?? null, + 'h' => $node['height'] ?? null + ]; + + // Add style information + if (isset($node['color']) || isset($node['borderColor']) || isset($node['font'])) { + $transformedNode['style'] = [ + 'fillColor' => $this->parseColor($node['color'] ?? null), + 'lineColor' => $this->parseColor($node['borderColor'] ?? null), + 'font' => $node['font'] ?? null + ]; + } + + $transformedNodes[] = $transformedNode; + } + + return $transformedNodes; + } + + /** + * Transform viewRelationships to include critical API fields + * + * @param array $viewRelationships Array of viewRelationship objects from XML + * @return array Array of transformed viewRelationships + */ + private function transformViewRelationships(array $viewRelationships): array + { + $transformedRelationships = []; + + foreach ($viewRelationships as $relationship) { + $transformedRelationship = $relationship; // Keep all existing fields + + // Add critical API fields + $transformedRelationship['identifier'] = $relationship['viewRelationshipId'] ?? null; + + // Add properties if available (check for relationship properties) + if (isset($relationship['properties'])) { + $transformedRelationship['properties'] = $relationship['properties']; + } else { + // Create properties array with relationship name if available + $properties = []; + if (isset($relationship['label'])) { + $properties[] = [ + 'propertyDefinitionRef' => 'propid-62', + 'value' => $relationship['label'] + ]; + } + $transformedRelationship['properties'] = $properties; + } + + // Ensure bendpoints are properly formatted + $transformedRelationship['bendpoints'] = $relationship['bendpoints'] ?? []; + + $transformedRelationships[] = $transformedRelationship; + } + + return $transformedRelationships; + } + + /** + * Parse color string to extract RGB values + * + * @param string|null $colorString Color string in format "rgb(r, g, b)" or null + * @return array|null Array with r, g, b, a values or null + */ + private function parseColor(?string $colorString): ?array + { + if (!$colorString) { + return null; + } + + // Handle rgb(r, g, b) format + if (preg_match('/rgb\((\d+),\s*(\d+),\s*(\d+)\)/', $colorString, $matches)) { + return [ + 'r' => (int)$matches[1], + 'g' => (int)$matches[2], + 'b' => (int)$matches[3], + 'a' => 100 + ]; + } + + return null; + } +} diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 1640e277..68b10d7f 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -1,5314 +1,4824 @@ { - "components": { - "registers": { - "voorzieningen": { - "slug": "voorzieningen", - "title": "Voorzieningen", - "version": "1.0.6", - "description": "Register voor voorzieningen uit de softwarecatalogus", - "schemas": [ - "sector", - "voorziening", - "voorzieningaanbod", - "voorzieningversie", - "kwetsbaarheid", - "contactpersoon", - "organisatie", - "voorzieninggebruik", - "contract", - "standaard", - "review", - "koppeling", - "beoordeeling", - "voorzieningModule", - "verklaring", - "koppelingGebruik", - "compliancy", - "moduleGebruik", - "moduleversie" - ], - "source": "internal", - "tablePrefix": "", - "folder": "500", - "updated": "2025-07-29T12:00:00+00:00", - "created": "2025-05-09T12:14:18+00:00", - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "authorization": null, - "groups": null, - "deleted": null - }, - "vng-gemma": { - "slug": "vng-gemma", - "title": "AMEF", - "version": "0.0.2", - "description": "", - "schemas": [ - "element", - "model", - "organization", - "property-definition", - "relation", - "view", - "extendview", - "property" - ], - "source": "", - "tablePrefix": "", - "folder": "Open Registers/VNG Gemma Register", - "updated": "2025-05-15T12:05:44+00:00", - "created": "2025-05-02T10:30:47+00:00", - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null + "components": { + "registers": { + "voorzieningen": { + "slug": "voorzieningen", + "title": "Voorzieningen", + "version": "1.0.7", + "description": "Register voor voorzieningen uit de softwarecatalogus", + "schemas": [ + "sector", + "product", + "dienst", + "kwetsbaarheid", + "contactpersoon", + "organisatie", + "gebruik", + "contract", + "koppeling", + "beoordeeling", + "module", + "compliancy", + "moduleVersie" + ], + "source": "internal", + "tablePrefix": "", + "folder": "500", + "updated": "2025-07-29T12:00:00+00:00", + "created": "2025-05-09T12:14:18+00:00", + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "authorization": null, + "groups": null, + "deleted": null + }, + "vng-gemma": { + "slug": "vng-gemma", + "title": "AMEF", + "version": "0.0.3", + "description": "Register voor AMEF (ArchiMate Model Exchange Format) modellen en architectuur elementen", + "schemas": [ + "element", + "model", + "organization", + "property-definition", + "relation", + "view", + "property" + ], + "source": "", + "tablePrefix": "", + "folder": "Open Registers/VNG Gemma Register", + "updated": "2025-05-15T12:05:44+00:00", + "created": "2025-05-02T10:30:47+00:00", + "owner": null, + "application": null, + "organisation": null, + "authorization": null, + "deleted": null + } + }, + "endpoints": { + "register": { + "name": "Register", + "description": "Endpoint voor het registreren van organisaties in de softwarecatalogus", + "reference": "", + "version": "0.0.3", + "endpoint": "register", + "endpointArray": ["register"], + "endpointRegex": "#^register$#", + "method": "POST", + "targetType": "register/schema", + "targetId": "voorzieningen/organisatie", + "conditions": [], + "inputMapping": null, + "outputMapping": null, + "configurations": [], + "slug": "register", + "created": "2025-05-16T13:11:49+00:00", + "updated": "2025-05-16T13:38:20+00:00" + }, + "views": { + "name": "Views", + "description": "", + "reference": "https://vng.accept.commonground.nu/Endpoint-Views.json", + "version": "0.0.1", + "endpoint": "views", + "endpointArray": [ + "views" + ], + "endpointRegex": "#^views$#", + "method": "GET", + "targetType": "register/schema", + "targetId": "vng-gemma/view", + "conditions": [], + "inputMapping": null, + "outputMapping": null, + "rules": [], + "configurations": [], + "slug": "views", + "created": "2025-05-01T14:48:08+00:00", + "updated": "2025-05-02T10:39:14+00:00" + }, + "view": { + "name": "View", + "description": "", + "reference": "https://vng.accept.commonground.nu/Endpoint-View.json", + "version": "0.0.1", + "endpoint": "views/{{id}}", + "endpointArray": [ + "views", + "{{id}}" + ], + "endpointRegex": "#^views(/([^/]+))$#", + "method": "GET", + "targetType": "register/schema", + "targetId": "vng-gemma/view", + "conditions": [], + "inputMapping": null, + "outputMapping": null, + "rules": [], + "configurations": [], + "slug": "view", + "created": "2025-05-01T14:48:08+00:00", + "updated": "2025-05-02T10:39:24+00:00" + } + }, + "schemas": { + "property": { + "uri": null, + "slug": "property", + "title": "Property", + "description": "Schema voor generieke eigenschappen die aan voorzieningen of relaties kunnen hangen", + "version": "0.0.4", + "summary": "", + "icon": "Tag", + "required": ["name", "type"], + "properties": { + "name": { + "description": "Naam van de eigenschap", + "type": "string", + "minLength": 1, + "maxLength": 200, + "title": "Naam" + }, + "type": { + "description": "Datatype van de eigenschap", + "type": "string", + "enum": ["string", "number", "boolean", "date"], + "title": "Type" + }, + "value": { + "description": "Waarde van de eigenschap", + "type": "string", + "maxLength": 1000, + "title": "Waarde" + }, + "lang": { + "description": "Taalcode (optioneel)", + "type": "string", + "minLength": 2, + "maxLength": 2, + "title": "Taal" + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-08-10T00:00:00+00:00", + "created": "2025-08-10T00:00:00+00:00", + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": null + }, + "sector": { + "uri": null, + "slug": "sector", + "title": "Sector", + "description": "Schema voor sectoren binnen de softwarecatalogus", + "version": "0.0.5", + "summary": "", + "icon": "Domain", + "required": ["naam"], + "properties": { + "naam": { + "description": "Naam van de sector", + "type": "string", + "required": true, + "visible": true, + "order": 1, + "facetable": false, + "title": "Naam", + "maxLength": 200, + "example": "Bijvoorbeeld: Overheid" + }, + "beschrijving": { + "description": "Beschrijving van de sector", + "type": "string", + "visible": true, + "order": 2, + "facetable": false, + "title": "Beschrijving", + "maxLength": 1000, + "example": "Bijvoorbeeld: Publieke sector en overheidsdiensten" + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "immutable": false, + "updated": "2025-05-13T19:35:39+00:00", + "created": "2025-05-01T14:49:42+00:00", + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": { + "objectNameField": "naam", + "objectDescriptionField": "beschrijving" + } + }, + "product": { + "uri": null, + "slug": "product", + "title": "Product", + "description": "Een product, suite of monobrand", + "version": "0.0.84", + "summary": "", + "icon": "ApplicationCog", + "required": ["naam", "beschrijvingKort"], + "properties": { + "naam": { + "description": "Naam van het product,suite of monobrand", + "type": "string", + "required": true, + "visible": true, + "order": 1, + "minLength": null, + "maxLength": 200, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Naam", + "example": "Voorbeeld: VNG Product Suite" + }, + "beschrijvingLang": { + "description": "Beschrijving van het product, suite of monobrand voor op de detailpagina", + "type": "string", + "format": "markdown", + "visible": true, + "order": 5, + "minLength": null, + "maxLength": 5000, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Uitgebreide omschrijving", + "example": "Bijvoorbeeld: Een uitgebreide beschrijving van het product met alle functionaliteiten en kenmerken" + }, + "beschrijvingKort": { + "type": "string", + "title": "Korte omschrijving", + "description": "Korte beschrijving van het product, suite of monobrand voor in de weergave in tabellen en zoekresultaten", + "facetable": false, + "maxLength": 255, + "order": 3, + "example": "Bijvoorbeeld: Een korte samenvatting van het product" + }, + "website": { + "description": "Website van het product", + "type": "string", + "format": "url", + "required": true, + "visible": true, + "order": 1, + "minLength": null, + "maxLength": 500, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Website", + "example": "https://voorbeeld.nl/product" + }, + "contactpersoon": { + "description": "Contactpersoon voor het product, suite of monobrand", + "type": "object", + "visible": true, + "order": 0, + "facetable": false, + "title": "Contact", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/contactpersoon" + }, + "cloudDienstverleningsmodel": { + "description": "Het cloud dienstverleningsmodel voor het product, suite of monobrand", + "type": "array", + "items": { + "type": "string", + "enum": ["On-premises (self-managed)", "IaaS", "PaaS", "SaaS"] + }, + "order": 0, + "objectConfiguration": {}, + "fileConfiguration": {}, + "oneOf": [], + "facetable": true, + "title": "Hosting vorm", + "example": ["SaaS"] + }, + "hostingJurisdictie": { + "description": "De jurisdictie waar de hosting plaatsvindt", + "type": "string", + "visible": true, + "order": 0, + "enum": ["NL", "EU", "US", "Elders"], + "facetable": true, + "title": "In welk land wordt de data opgeslagen?", + "example": "Bijvoorbeeld: NL" + }, + "hostingLocatie": { + "description": "De locatie waar de hosting plaatsvindt", + "type": "string", + "visible": true, + "order": 0, + "enum": ["NL", "EU", "US", "Elders"], + "facetable": true, + "title": "Waar wordt de applicatie gehost?", + "example": "Bijvoorbeeld: NL" + }, + "logo": { + "description": "URL naar het logo van het product, suite of monobrand", + "type": "string", + "format": "url", + "visible": true, + "order": 4, + "objectConfiguration": {}, + "fileConfiguration": {}, + "oneOf": [], + "facetable": false, + "title": "Logo", + "example": "https://voorbeeld.nl/logo.png" + }, + "aanbieder": { + "description": "De aanbieder van het product, suite of monobrand", + "type": "object", + "visible": true, + "order": 2, + "facetable": false, + "title": "Aanbieder", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/organisatie" + }, + "modules": { + "description": "De applicaties en systeemsoftware die onderdeel zijn van dit product, suite of monobrand", + "type": "array", + "visible": true, + "order": 12, + "facetable": false, + "title": "Modules", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "product" + } + }, + "omvat": { + "description": "Andere producten, suites of monobrands die onderdeel zijn van dit product", + "type": "array", + "visible": true, + "order": 13, + "facetable": false, + "title": "Omvat", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/product", + "inversedBy": "onderdeelVan" + } + }, + "onderdeelVan": { + "description": "Producten, suites of monobrands waarvan dit product, suite of monobrand onderdeel is", + "type": "array", + "visible": true, + "order": 14, + "facetable": false, + "title": "Onderdeel van", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/product", + "inversedBy": "omvat" + } + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-07-29T09:35:54+00:00", + "created": "2025-05-09T12:14:18+00:00", + "maxDepth": 0, + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": { + "objectNameField": "naam", + "objectSummaryField": "beschrijvingKort", + "objectDescriptionField": "beschrijvingLang", + "objectImageField": "logo", + "allowFiles": true, + "allowedTags": ["DPIA", "Handleiding"], + "autoPublish": true + } + }, + "dienst": { + "uri": null, + "slug": "dienst", + "title": "Dienst", + "description": "Een specifiek aanbod van een dienst op een of meerdere producten door een leverancier", + "version": "0.0.40", + "summary": "", + "icon": "Handshake", + "required": ["naam", "producten", "aanbieder"], + "properties": { + "naam": { + "description": "De naam van de dienst", + "type": "string", + "required": true, + "visible": true, + "order": 1, + "minLength": null, + "maxLength": 200, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Naam", + "example": "Bijvoorbeeld: Implementatie en ondersteuning" + }, + "beschrijvingKort": { + "type": "string", + "description": "Korte beschrijving van de dienst", + "title": "Samenvatting", + "facetable": false, + "maxLength": 255, + "order": 5, + "example": "Bijvoorbeeld: Korte beschrijving van de dienst" + }, + "beschrijvingLang": { + "description": "Uitgebreide beschrijving van de dienst", + "type": "string", + "format": "markdown", + "visible": true, + "order": 6, + "facetable": false, + "title": "Beschrijving", + "maxLength": 5000, + "example": "Bijvoorbeeld: Uitgebreide beschrijving van de dienst met alle details" + }, + "website": { + "type": "string", + "format": "url", + "description": "De website waarop meer informatie over dit aanbod te vinden is", + "facetable": false, + "title": "Website", + "order": 4, + "visible": true, + "maxLength": 500, + "example": "https://dienst.voorbeeld.nl" + }, + "status": { + "description": "De status van dit aanbod", + "type": "string", + "default": "concept", + "example": "Bijvoorbeeld: concept" + }, + "contactpersoon": { + "description": "Contactpersoon voor deze dienst", + "type": "object", + "visible": true, + "order": 1, + "facetable": false, + "title": "Contactpersoon", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/contactpersoon" + }, + "producten": { + "description": "Welke producten worden via de dienst aangeboden", + "type": "array", + "required": true, + "visible": true, + "order": 2, + "facetable": false, + "title": "Producten", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/product" + } + }, + "aanbieder": { + "description": "De leverende partij die deze dienst beschikbaar stelt", + "type": "object", + "required": true, + "visible": true, + "order": 3, + "facetable": false, + "title": "Aanbieder", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/organisatie" + }, + "type": { + "description": "Het type dienst dat wordt aangeboden", + "type": "string", + "visible": true, + "order": 4, + "facetable": true, + "title": "Soort dienst", + "enum": [ + "Functioneel beheer", + "Applicatiebeheer", + "Technisch beheer", + "Implementatieondersteuning", + "Opleidingen", + "Licentiereseller" + ], + "example": "Bijvoorbeeld: Implementatieondersteuning" + }, + "logo": { + "description": "URL naar het logo van de dienst", + "type": "string", + "format": "url", + "visible": true, + "order": 5, + "facetable": false, + "title": "Logo", + "example": "https://dienst.voorbeeld.nl/logo.png" + }, + "modules": { + "description": "De modules die onderdeel zijn van deze dienst", + "type": "array", + "visible": true, + "order": 7, + "facetable": false, + "title": "Modules", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "dienst" + } + }, + "koppelingen": { + "description": "Koppelingen die gebruikt worden door deze dienst", + "type": "array", + "visible": true, + "order": 8, + "facetable": false, + "title": "Koppelingen", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/koppeling", + "inversedBy": "dienst" + } + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-07-29T09:35:54+00:00", + "created": "2025-05-09T12:14:18+00:00", + "maxDepth": 0, + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": { + "objectNameField": "naam", + "objectSummaryField": "beschrijvingKort", + "objectDescriptionField": "beschrijvingLang", + "objectImageField": "logo", + "allowFiles": true, + "allowedTags": [ + "ISO-9001", + "ISO-27001", + "ISO-16075", + "Verklaring van toepasselijkheid" + ], + "autoPublish": true + } + }, + "kwetsbaarheid": { + "uri": null, + "slug": "kwetsbaarheid", + "title": "Kwetsbaarheid", + "description": "Schema voor kwetsbaarheden", + "version": "1.0.14", + "summary": "", + "icon": "ShieldAlert", + "required": ["naam", "beschrijvingKort", "modules"], + "properties": { + "naam": { + "description": "Naam van de kwetsbaarheid", + "type": "string", + "visible": true, + "order": 1, + "facetable": false, + "title": "Naam", + "maxLength": 200, + "example": "Bijvoorbeeld: SQL Injection" + }, + "beschrijvingKort": { + "description": "Korte beschrijving van de kwetsbaarheid", + "type": "string", + "maxLength": 255, + "visible": true, + "order": 2, + "facetable": false, + "title": "Samenvatting", + "example": "Bijvoorbeeld: Korte beschrijving van de kwetsbaarheid" + }, + "beschrijvingLang": { + "description": "Uitgebreide beschrijving van de kwetsbaarheid", + "type": "string", + "format": "markdown", + "visible": true, + "order": 3, + "facetable": false, + "title": "Beschrijving", + "maxLength": 5000, + "example": "Bijvoorbeeld: Uitgebreide beschrijving van de kwetsbaarheid" + }, + "cveCode": { + "description": "CVE (Common Vulnerabilities and Exposures) identificatiecode", + "type": "string", + "pattern": "^CVE-\\d{4}-\\d{4,}$", + "visible": true, + "order": 4, + "facetable": true, + "title": "CVE Code", + "maxLength": 20, + "example": "Bijvoorbeeld: CVE-2021-44228" + }, + "cvssScore": { + "description": "CVSS (Common Vulnerability Scoring System) score van 0.0 tot 10.0", + "type": "number", + "minimum": 0.0, + "maximum": 10.0, + "visible": true, + "order": 5, + "facetable": true, + "title": "CVSS Score", + "example": "Bijvoorbeeld: 9.8" + }, + "modules": { + "description": "De modules die door deze kwetsbaarheid getroffen worden", + "type": "array", + "visible": true, + "order": 6, + "facetable": false, + "title": "Getroffen Modules", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "kwetsbaarheid" + } + } + }, + "archive": [], + "source": "internal", + "hardValidation": true, + "immutable": false, + "updated": "2025-05-09T12:14:18+00:00", + "created": "2025-05-09T12:14:18+00:00", + "maxDepth": 0, + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": { + "objectNameField": "naam", + "objectSummaryField": "beschrijvingKort", + "objectDescriptionField": "beschrijvingLang" + } + }, + "contactpersoon": { + "uri": null, + "slug": "contactpersoon", + "title": "Contactpersoon", + "description": "Contactgegevens van een persoon", + "version": "0.0.22", + "summary": "", + "icon": "AccountMultiple", + "required": ["e-mailadres"], + "properties": { + "voornaam": { + "type": "string", + "description": "Voornaam van de contactpersoon", + "facetable": false, + "title": "Voornaam", + "order": 1, + "maxLength": 100, + "example": "Bijvoorbeeld: Jan" + }, + "tussenvoegsel": { + "type": "string", + "description": "Tussenvoegsel van de contactpersoon", + "facetable": false, + "title": "Tussenvoegsel", + "order": 2, + "maxLength": 20, + "example": "Bijvoorbeeld: van" + }, + "achternaam": { + "type": "string", + "description": "Achternaam van de contactpersoon", + "facetable": false, + "title": "Achternaam", + "order": 3, + "maxLength": 100, + "example": "Bijvoorbeeld: Jansen" + }, + "functie": { + "description": "Functie van de medewerker", + "type": "string", + "visible": true, + "minLength": null, + "maxLength": 100, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Functie", + "order": 4, + "example": "Bijvoorbeeld: Beheerder" + }, + "organisatie": { + "type": "object", + "title": "Organisatie", + "description": "De organisatie waartoe deze contactpersoon behoort", + "visible": false, + "hideOnCollection": true, + "facetable": false, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/organisatie", + "order": 9 + }, + "username": { + "description": "Gebruikersnaam van de contactpersoon", + "title": "Gebruikersnaam", + "type": "string", + "visible": false, + "hideOnCollection": true, + "facetable": false, + "order": 10, + "minLength": null, + "maxLength": 50, + "immutable": true, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "telefoonnummer": { + "type": "string", + "description": "Telefoonnummer van de contactpersoon", + "facetable": false, + "title": "Telefoonnummer", + "order": 5, + "example": "Bijvoorbeeld: 06 12345678" + }, + "isAanspreekpunt": { + "type": "boolean", + "title": "Is aanspreekpunt", + "description": "Geeft aan of deze persoon het aanspreekpunt is voor deze organisatie (publiek word gedeeld", + "facetable": false, + "hideOnCollection": true, + "order": 8, + "example": "Bijvoorbeeld: true" + }, + "notificaties": { + "type": "array", + "title": "Notificaties", + "description": "Lijst van notificaties voor deze contactpersoon", + "facetable": false, + "hideOnCollection": true, + "items": { + "type": "string", + "enum": [ + "Nieuw in organisatie", + "Nieuw buiten de organisatie", + "Gewijzigd in de organisatie" + ] + }, + "order": 11, + "example": "Bijvoorbeeld: ['Nieuw in organisatie', 'Gewijzigd in de organisatie']" + }, + "rollen": { + "description": "De rollen die deze contactpersoon heeft", + "title": "Rollen", + "type": "array", + "visible": true, + "facetable": false, + "order": 7, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "register": "", + "writeBack": false, + "removeAfterWriteBack": false, + "items": { + "type": "string" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "enum": [ + "Aanbod-beheerder", + "Gebruik-beheerder", + "Functioneel-beheerder", + "Organisatie-beheerder" + ], + "example": "Bijvoorbeeld: [\"Aanbod-beheerder\", \"Functioneel-beheerder\"]" + }, + "e-mailadres": { + "description": "E-mailadres van de contactpersoon", + "title": "E-mailadres", + "type": "string", + "format": "email", + "required": true, + "visible": true, + "facetable": false, + "order": 6, + "minLength": null, + "maxLength": 320, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "register": "", + "writeBack": false, + "removeAfterWriteBack": false, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "example": "Bijvoorbeeld: jan.jansen@organisatie.nl" + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-06-05T11:53:54+00:00", + "created": "2025-05-09T12:14:18+00:00", + "maxDepth": 0, + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": { + "objectNameField": "achternaam", + "objectDescriptionField": "functie", + "autoPublish": true + } + }, + "organisatie": { + "uri": null, + "slug": "organisatie", + "title": "Organisatie", + "description": "Een organisatie die voorzieningen aanbiedt", + "version": "0.0.93", + "summary": "", + "icon": "OfficeBuildingOutline", + "required": ["naam", "type", "website"], + "properties": { + "naam": { + "description": "Naam van de organisatie", + "type": "string", + "required": true, + "visible": true, + "order": 1, + "minLength": null, + "maxLength": 200, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Naam", + "example": "Bijvoorbeeld: VNG Realisatie" + }, + "beschrijvingKort": { + "description": "Beschrijving van de leverancier", + "type": "string", + "visible": true, + "order": 1, + "facetable": false, + "title": "Samenvatting", + "maxLength": 255 + }, + "beschrijvingLang": { + "description": "Overige informatie", + "type": "string", + "visible": true, + "order": 2, + "facetable": false, + "title": "Beschrijving", + "format": "markdown", + "maxLength": 5000 + }, + "logo": { + "description": "Logo van de organisatie", + "type": "string", + "format": "uri", + "visible": true, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Logo", + "order": 10 + }, + "cbsCode": { + "description": "CBS nummer van de organisatie", + "type": "number", + "visible": true, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "CBS Nummer", + "order": 3 + }, + "kvkNummer": { + "description": "KvK-nummer van de organisatie", + "type": "string", + "visible": true, + "order": 9, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "KvK Nummer", + "example": "12345678" + }, + "contactpersonen": { + "description": "De contactpersoon van de organisatie", + "type": "array", + "visible": true, + "order": 4, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "items": { + "cascadeDelete": false, + "$ref": "#/components/schemas/contactpersoon", + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "inversedBy": "organisatie" + }, + "objectConfiguration": { + "handling": null, + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Contactpersonen" + }, + "e-mailadres": { + "description": "Het e-mailadres van de contactpersoon of de organisatie", + "type": "string", + "visible": true, + "order": 7, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "E-mailadres", + "example": "contact@organisatie.nl" + }, + "website": { + "description": "URL van de website van de organisatie", + "title": "Website", + "type": "string", + "required": true, + "visible": true, + "facetable": false, + "order": 16, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "example": "https://www.organisatie.nl" + }, + "telefoonnummer": { + "description": "Telefoonnummer van de contactpersoon of de organisatie", + "title": "Telefoonnummer", + "type": "string", + "visible": true, + "facetable": false, + "order": 15, + "minLength": null, + "maxLength": null, + "example": "06 12345678", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "deelnames": { + "description": "Deelnames van deze organisatie in andere organisaties", + "type": "array", + "visible": true, + "order": 5, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "items": { + "cascadeDelete": false, + "$ref": "#/components/schemas/organisatie", + "type": "object", + "objectConfiguration": { + "handling": "related-object" + } + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Deelnames" + }, + "deelnemers": { + "description": "Deelnemers in deze organisatie", + "type": "array", + "visible": true, + "order": 6, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "items": { + "cascadeDelete": false, + "$ref": "#/components/schemas/organisatie", + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "inversedBy": "deelnames" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Deelnemers", + "writeBack": true, + "removeAfterWriteBack": false + }, + "type": { + "description": "Type van de organisatie (Gemeente, Leverancier, Samenwerking) ", + "title": "Organisatie Type", + "type": "string", + "required": true, + "visible": true, + "facetable": true, + "order": 3, + "minLength": null, + "maxLength": null, + "immutable": true, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "enum": ["Gemeente", "Leverancier", "Samenwerking", "Community"] + }, + "status": { + "description": "Geeft aan of de VNG de organisatie positief beoordeeld heeft voor toegang tot de Softwarecatalogus", + "title": "Status", + "type": "string", + "default": "concept", + "visible": false, + "hideOnCollection": true, + "facetable": false, + "order": 17, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "enum": ["Concept", "Actief", "Deactief"] + }, + "samenwerkingtype": { + "description": "Type samenwerking van de organisatie", + "title": "Samenwerkingstype", + "type": "string", + "visible": false, + "hideOnCollection": true, + "facetable": true, + "order": 14, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "enum": [ + "Uitvoeringsorganisatie", + "Sociaal Domein samenwerking", + "Shared Service Center", + "samenwerkingtype", + "Omgevingsdienst", + "ICT (bijvoorbeeld Shared Service Center)", + "Gemeentelijke herindeling (gepland)", + "Gemeenschappelijke Regeling (samenwerking meerdere domeinen)", + "Gemeenschappelijke Regeling", + "DVO", + "Centrumgemeenteregeling", + "Belastingsamenwerking", + "Bedrijfsvoeringsorganisatie", + "Archiefdienst (regionaal)", + "Ambtelijke fusie" + ] + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-07-29T09:35:54+00:00", + "created": "2025-05-09T12:14:18+00:00", + "maxDepth": 0, + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "public", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": { + "objectNameField": "naam", + "objectSummaryField": "beschrijvingKort", + "objectDescriptionField": "beschrijvingLang", + "objectImageField": "logo", + "allowFiles": true, + "allowedTags": [ + "Verklaring betaling sociale premies", + "Verklaring betaling belastingen", + "Bestuurdersverklaring", + "Uittreksel KvK", + "BTW-nummer bevestiging", + "Compliance verklaring", + "Jaarrekening", + "ISO-certificaten", + "Privacy verklaring", + "AVG compliance document", + "ESPD (Europees aanbestedingsdocument)", + "Integriteitsverklaring", + "Financiële capaciteitsverklaring", + "Technische capaciteitsverklaring", + "Kwaliteitscertificaten", + "Milieucertificaten", + "Verzekeringsbewijs", + "Beroepsaansprakelijkheidsverzekering", + "Referentieprojecten", + "VCA-certificaat", + "BRL-certificaten", + "CE-markering documenten", + "Aanbestedingsdocumentatie" + ], + "autoPublish": true + } + }, + "gebruik": { + "uri": null, + "slug": "gebruik", + "title": "Gebruik", + "description": "Het gebruik van producten, modules, diensten en koppelingen door afnemers", + "version": "1.0.3", + "summary": "", + "icon": "Usage", + "required": ["afnemer", "product", "status"], + "properties": { + "afnemer": { + "type": "object", + "title": "Afnemer", + "description": "De organisatie die afnemer is van het product", + "facetable": false, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/organisatie", + "required": true, + "order": 11 + }, + "product": { + "type": "object", + "title": "Product", + "description": "Het product dat gebruikt wordt", + "facetable": false, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/product", + "required": true, + "order": 19 + }, + "contactpersoon": { + "type": "object", + "description": "De contactpersoon voor dit productgebruik", + "facetable": false, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/contactpersoon", + "title": "Contactpersoon", + "order": 3 + }, + "deelnemers": { + "description": "De organisaties die deelnemen aan dit gebruik (voor samenwerkingen)", + "type": "array", + "visible": true, + "facetable": false, + "title": "Deelnemers", + "order": 6, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/organisatie" } + }, + "startDatumVerwerving": { + "description": "De start datum voor het \"Verwerving\" status", + "type": "string", + "format": "date", + "visible": true, + "order": 14, + "facetable": false, + "title": "Startdatum Verwerving", + "example": "Bijvoorbeeld: 2025-01-01" + }, + "startDatumGepland": { + "description": "De start datum voor het \"Gepland\" status", + "type": "string", + "format": "date", + "visible": true, + "order": 16, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Geplande Startdatum", + "example": "Bijvoorbeeld: 2025-02-01" + }, + "startDatumInProductie": { + "description": "De start datum voor het \"actief\" status", + "type": "string", + "format": "date", + "visible": true, + "order": 14, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Startdatum In Productie", + "example": "Bijvoorbeeld: 2025-03-01" + }, + "startDatumUitTeFaseren": { + "description": "De start datum voor het \"Beëindigd\" status", + "type": "string", + "format": "date", + "visible": true, + "order": 15, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Startdatum Uit Te Faseren", + "example": "Bijvoorbeeld: 2025-12-31" + }, + "startDatumUitGefaseerd": { + "description": "De start datum voor het \"Uit gefaseerd\" status", + "type": "string", + "format": "date", + "visible": true, + "order": 15, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Startdatum Uit Gefaseerd", + "example": "Bijvoorbeeld: 2025-12-31" + }, + "status": { + "description": "De status van het gebruik", + "type": "string", + "default": "concept", + "required": true, + "visible": true, + "order": 17, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "enum": [ + "Verwerving", + "Gepland", + "In productie", + "Uit te faseren", + "Uitgefaseerd" + ], + "facetable": false, + "title": "Status", + "example": "Bijvoorbeeld: Gepland" + }, + "interneAantekening": { + "description": "Aanvullende interne informatie over het gebruik van de voorziening", + "type": "string", + "visible": true, + "hideOnCollection": true, + "order": 10, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [], + "facetable": false, + "title": "Interne Aantekening", + "example": "Bijvoorbeeld: Interne notitie over het gebruik" + }, + "module": { + "description": "De specifieke module die gebruikt wordt", + "type": "object", + "visible": true, + "order": 20, + "facetable": false, + "title": "Module", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "gebruik" + }, + "moduleVersie": { + "description": "De specifieke versie van de module die gebruikt wordt", + "type": "object", + "visible": true, + "order": 21, + "facetable": false, + "title": "Module Versie", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/moduleVersie", + "inversedBy": "gebruik" + }, + "gebruiktVoorReferentiecomponenten": { + "description": "GEMMA referentiecomponenten waarvoor dit product wordt gebruikt", + "type": "array", + "visible": true, + "order": 22, + "facetable": false, + "title": "Referentiecomponenten", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object", + "queryParams": "gemmaType=referentiecomponent&_extend=aanbevolenStandaarden,verplichteStandaarden" + }, + "$ref": "#/components/schemas/element" + } + }, + "amefElements": { + "description": "Ids van AMEF elementen waarvoor dit product wordt gebruikt", + "type": "array", + "visible": true, + "order": 22, + "facetable": false, + "title": "Amef elementen", + "items": { + "type": "string" + } + }, + "koppelingen": { + "description": "De koppelingen die gebruikt worden binnen dit productgebruik", + "type": "array", + "visible": true, + "order": 24, + "facetable": false, + "title": "Koppelingen", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/koppeling" + } + }, + "diensten": { + "description": "De diensten die onderdeel zijn van dit gebruik", + "type": "array", + "visible": true, + "order": 25, + "facetable": false, + "title": "Diensten", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/dienst" + } + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-07-29T09:35:54+00:00", + "created": "2025-05-09T12:14:18+00:00", + "maxDepth": 0, + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] }, - "schemas": { - "property": { - "uri": null, - "slug": "property", - "title": "Property", - "description": "Schema voor generieke eigenschappen die aan voorzieningen of relaties kunnen hangen", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "description": "Naam van de eigenschap", - "type": "string", - "minLength": 1, - "title": "Naam" - }, - "type": { - "description": "Datatype van de eigenschap", - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "date" - ], - "title": "Type" - }, - "value": { - "description": "Waarde van de eigenschap", - "type": "string", - "title": "Waarde" - }, - "lang": { - "description": "Taalcode (optioneel)", - "type": "string", - "minLength": 2, - "maxLength": 2, - "title": "Taal" - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-08-10T00:00:00+00:00", - "created": "2025-08-10T00:00:00+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "sector": { - "uri": null, - "slug": "sector", - "title": "sector", - "description": "", - "version": "0.0.2", - "summary": "", - "icon": null, - "required": [], - "properties": [], - "archive": [], - "source": "", - "hardValidation": false, - "immutable": false, - "updated": "2025-05-13T19:35:39+00:00", - "created": "2025-05-01T14:49:42+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "beschrijving" - } - }, - "voorziening": { - "uri": null, - "slug": "voorziening", - "title": "Product", - "description": "Een generieke voorziening (software product)", - "version": "0.0.80", - "summary": "", - "icon": null, - "required": [ - "naam" - ], - "properties": { - "licentietype": { - "description": "", - "type": "string", - "visible": true, - "order": 9, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "enum": [ - "Closed Source", - "Open Source" - ], - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Licentietype" - }, - "licentie": { - "description": "", - "type": "string", - "visible": true, - "order": 10, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "enum": [ - "MIT License", - "GNU General Public License (GPL)", - "Apache License 2.0", - "BSD Licentie (Berkeley Software Distribution)", - "European Union Public Licence (EUPL), versie 1.2" - ], - "title": "Licentie" - }, - "contact": { - "description": "", - "type": "string", - "visible": true, - "order": 0, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Contact" - }, - "hosting": { - "description": "", - "type": "array", - "visible": true, - "order": 0, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Self manged", - "IaaS", - "PaaS", - "SaaS" - ], - "facetable": true, - "title": "Hosting", - "items": { - "type": "string" - } - }, - "logo": { - "description": "", - "type": "string", - "visible": true, - "order": 0, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Logo" - }, - "naam": { - "description": "Naam van de applicatie", - "type": "string", - "required": true, - "visible": true, - "order": 1, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Naam" - }, - "organisatie": { - "description": "", - "type": "string", - "visible": true, - "order": 4, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Organisatie" - }, - "referentieComponenten": { - "description": "GEMMA referentiecomponenten die de voorziening implementeert", - "type": "array", - "visible": true, - "order": 7, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "items": { - "type": "string", - "format": "uuid" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": true, - "title": "Referentiecomponenten" - }, - "standaarden": { - "description": "Array van UUID's die verwijzen naar de standaarden die de voorziening ondersteunt", - "type": "array", - "visible": true, - "order": 8, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "items": { - "type": "string", - "format": "uuid" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Standaarden" - }, - "status": { - "description": "", - "type": "string", - "default": "concept", - "visible": true, - "order": 6, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "In ontwikkeling", - "In gebruik", - "Einde ondersteuning", - "Teruggetrokken" - ], - "facetable": false, - "title": "Status" - }, - "voorzieningstype": { - "description": "Het type voorziening zoals geregistreerd in de catalogus", - "type": "string", - "visible": true, - "order": 5, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "IT-bouwsteen", - "enum": [ - "Applicatie", - "Infrastructuur" - ] - }, - "beschrijvingLang": { - "description": "De lange beschrijving van de voorziening", - "type": "string", - "visible": true, - "order": 2, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Lange beschrijving" - }, - "beschrijvingKort": { - "type": "string", - "title": "BeschrijvingKort", - "description": "De korte beschrijving van de applicatie", - "facetable": false, - "maxLength": 100, - "order": 1 - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-07-29T09:35:54+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "beschrijvingKort" - } - }, - "voorzieningaanbod": { - "uri": null, - "slug": "voorzieningaanbod", - "title": "Dienst", - "description": "Een specifiek aanbod van een voorziening door een leverancier (ofwel een dienst)", - "version": "0.0.37", - "summary": "", - "icon": null, - "required": [ - "naam", - "voorziening", - "leverancier" - ], - "properties": { - "productpagina": { - "type": "string", - "format": "url", - "description": "De product pagina (website) waarop meer invoormatie over dit aanbod te vinden is", - "facetable": false, - "title": "Product pagina", - "order": 4 - }, - "ondersteundeStandaarden": { - "type": "array", - "description": "Lijst van ondersteunde standaarden", - "items": { - "$ref": "#/components/schemas/Standaard", - "type": "object", - "objectConfiguration": { - "handling": "nested-object" - } - }, - "facetable": false, - "title": "OndersteundeStandaarden", - "order": 8 - }, - "status": { - "description": "De status van dit aanbod", - "type": "string", - "default": "concept", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Status", - "order": 11 - }, - "laag": { - "description": "", - "type": "string", - "visible": true, - "order": 3, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Laag" - }, - "contact": { - "description": "", - "type": "string", - "visible": true, - "order": 1, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Contact" - }, - "hosting": { - "description": "De hosting waaronder deze voorziening wordt aangeboden", - "type": "string", - "default": "none", - "order": 2, - "minLength": null, - "maxLength": null, - "example": "on-premise", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Hosting" - }, - "naam": { - "description": "De naam van dit aanbo", - "type": "string", - "required": true, - "visible": true, - "order": 1, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Naam" - }, - "voorziening": { - "description": "Welke voorziening word via aangbod aangeboden", - "type": "string", - "format": "uuid", - "required": true, - "visible": true, - "order": 2, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Voorziening" - }, - "leverancier": { - "description": "De lverende partij die dit aanbod beschickbaar stelt", - "type": "object", - "required": true, - "visible": true, - "order": 3, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "#/components/schemas/Organisatie", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Leverancier" - }, - "hostingJurisdictie": { - "type": "string", - "description": "Jurisdictie van de hosting", - "title": "Hosting jurisdictie", - "enum": [ - "EU", - "US" - ], - "facetable": true, - "order": 10 - }, - "hostingprovider": { - "type": "string", - "description": "Verwijzing naar de voorziening van type fysiek rekencentrum", - "title": "Hosting provider", - "facetable": false, - "order": 11 - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-07-29T09:35:54+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "productpagina" - } - }, - "voorzieningversie": { - "uri": null, - "slug": "voorzieningversie", - "title": "Module-versie", - "description": "Een specifieke versie van een voorzieningaanbod", - "version": "0.0.31", - "summary": "", - "icon": null, - "required": [ - "voorzieningaanbod", - "versienummer" - ], - "properties": { - "voorzieningaanbod": { - "$ref": "#/components/schemas/Voorzieningaanbod", - "type": "object", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "required": true, - "title": "Voorzieningaanbod", - "order": 15 - }, - "versienummer": { - "type": "string", - "description": "Versienummer van de voorziening", - "facetable": false, - "required": true, - "title": "Versienummer", - "order": 13 - }, - "releaseNotes": { - "type": "string", - "description": "Release notes voor deze versie", - "facetable": false, - "title": "ReleaseNotes", - "order": 7 - }, - "releaseDatum": { - "type": "string", - "format": "date", - "description": "Datum waarop deze versie is uitgebracht", - "facetable": false, - "title": "ReleaseDatum", - "order": 6 - }, - "status": { - "description": "", - "type": "string", - "visible": true, - "order": 8, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Status" - }, - "voorziening": { - "description": "", - "type": "string", - "visible": true, - "order": 2, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Applicatie" - }, - "datumOntwikkeling": { - "description": "Start van de ontwikkeling van de module", - "type": "string", - "format": "date", - "visible": true, - "order": 5, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Datum ontwikkeling" - }, - "datumTest": { - "description": "Start testfase van de module", - "type": "string", - "format": "date", - "visible": true, - "order": 2, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Datum test" - }, - "datumDistributie": { - "description": "Datum vanaf wanneer module beschikbaar is voor gebruik", - "type": "string", - "format": "date", - "visible": true, - "order": 4, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Datum distributie" - }, - "datumEindeOndersteuning": { - "description": "Datum vanaf wanneer module niet meer ondersteund wordt", - "type": "string", - "format": "date", - "visible": true, - "order": 3, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Datum einde ondersteuning" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-07-29T09:35:54+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "versienummer", - "objectDescriptionField": "status" - } - }, - "kwetsbaarheid": { - "uri": null, - "slug": "kwetsbaarheid", - "title": "Kwetsbaarheid", - "description": "Schema voor kwetsbaarheden", - "version": "1.0.11", - "summary": "", - "icon": null, - "required": [], - "properties": [], - "archive": [], - "source": "internal", - "hardValidation": true, - "immutable": false, - "updated": "2025-05-09T12:14:18+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": { - "read": [ - "testgroup" - ], - "create": [ - "testgroup", - "public" - ], - "update": [ - "testgroup" - ], - "delete": [ - "testgroup" - ] - }, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "beschrijving" - } - }, - "contactpersoon": { - "uri": null, - "slug": "contactpersoon", - "title": "Contactpersoon", - "description": "Contactgegevens van een persoon", - "version": "0.0.17", - "summary": "", - "icon": null, - "required": [ - "organisatie", - "e-mailadres" - ], - "properties": { - "voornaam": { - "type": "string", - "description": "Voornaam van de contactpersoon", - "facetable": false, - "title": "Voornaam", - "order": 11 - }, - "tussenvoegsel": { - "type": "string", - "description": "Tussenvoegsel van de contactpersoon", - "facetable": false, - "title": "Tussenvoegsel", - "order": 9 - }, - "achternaam": { - "type": "string", - "description": "Achternaam van de contactpersoon", - "facetable": false, - "title": "Achternaam", - "order": 1 - }, - "functie": { - "description": "Functie van de medewerker", - "type": "string", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Functie", - "order": 3 - }, - "organisatie": { - "type": "object", - "title": "Organisatie", - "description": "", - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organisatie", - "required": true, - "order": 6 - }, - "username": { - "description": "", - "title": "Username", - "type": "string", - "visible": true, - "facetable": false, - "order": 10, - "minLength": null, - "maxLength": null, - "immutable": true, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "telefoonnummer": { - "type": "string", - "description": "Telefoonnummer van de contactpersoon", - "facetable": false, - "title": "Telefoonnummer", - "order": 8 - }, - "isAanspreekpunt": { - "type": "boolean", - "title": "IsAanspreekpunt", - "description": "", - "facetable": false, - "order": 4 - }, - "notificaties": { - "type": "array", - "title": "Notificaties", - "description": "", - "facetable": false, - "items": { - "type": "string" - }, - "order": 5 - }, - "rollen": { - "description": "", - "title": "Rollen", - "type": "array", - "visible": true, - "facetable": false, - "order": 7, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "register": "", - "writeBack": false, - "removeAfterWriteBack": false, - "items": { - "type": "string" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Aanbod-beheerder", - "Gebruik-beheerder", - "Gebruik-raadpleger", - "Functioneel-beheerder", - "VNG-raadpleger", - "Organisatie-beheerder" - ] - }, - "e-mailadres": { - "description": "E-mailadres van de contactpersoon", - "title": "E-mailadres", - "type": "string", - "format": "email", - "required": true, - "visible": true, - "facetable": false, - "order": 2, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "register": "", - "writeBack": false, - "removeAfterWriteBack": false, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-06-05T11:53:54+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": { - "create": [ - "public" - ] - }, - "deleted": null, - "configuration": { - "objectNameField": "achternaam", - "objectDescriptionField": "functie" - } - }, - "organisatie": { - "uri": null, - "slug": "organisatie", - "title": "Organisatie", - "description": "Een organisatie die voorzieningen aanbiedt", - "version": "0.0.90", - "summary": "", - "icon": null, - "required": [ - "naam", - "type", - "website" - ], - "properties": { - "logo": { - "description": "Logo van de organisatie", - "type": "string", - "format": "uri", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Logo", - "order": 10 - }, - "cbs": { - "description": "", - "type": "number", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Cbs", - "order": 3 - }, - "beschrijvingKort": { - "description": "Beschrijving van de leverancier", - "type": "string", - "visible": true, - "order": 1, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "BeschrijvingKort" - }, - "beschrijvingLang": { - "description": "Overige informatie", - "type": "string", - "visible": true, - "order": 2, - "minLength": null, - "maxLength": 2000, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "BeschrijvingLang" - }, - "contactpersonen": { - "description": "De contactpersoon van de organisatie", - "type": "array", - "visible": true, - "order": 4, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "items": { - "cascadeDelete": false, - "$ref": "#/components/schemas/contactpersoon", - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "inversedBy": "organisatie" - }, - "objectConfiguration": { - "handling": null, - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Contactpersonen" - }, - "e-mailadres": { - "description": "Het e-mailadres van de contactpersoon of de organisatie", - "type": "string", - "visible": true, - "order": 7, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "E-mailadres" - }, - "kvkNummer": { - "description": "KvK-nummer van de organisatie", - "type": "string", - "visible": true, - "order": 9, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "KvkNummer" - }, - "naam": { - "description": "Naam van de organisatie", - "type": "string", - "required": true, - "visible": true, - "order": 1, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Naam" - }, - "deelnames": { - "description": "", - "type": "array", - "visible": true, - "order": 5, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "items": { - "cascadeDelete": false, - "$ref": "#/components/schemas/organisatie", - "type": "object", - "objectConfiguration": { - "handling": "related-object" - } - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Deelnames" - }, - "deelnemers": { - "description": "", - "type": "array", - "visible": true, - "order": 6, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "items": { - "cascadeDelete": false, - "$ref": "#/components/schemas/organisatie", - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "inversedBy": "deelnames" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Deelnemers", - "writeBack": true, - "removeAfterWriteBack": false - }, - "group": { - "description": "De gebruikers groep voor contactpersonen van deze organisatie", - "title": "Group", - "type": "integer", - "facetable": false, - "order": 8, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "type": { - "description": "Type van de organisatie (Gemeente, Leverancier, Samenwerking) ", - "title": "Organisatie type", - "type": "string", - "required": true, - "visible": true, - "facetable": true, - "order": 3, - "minLength": null, - "maxLength": null, - "immutable": true, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Gemeente", - "Leverancier", - "Samenwerking", - "Community" - ] - }, - "telefoonnummer": { - "description": "Telefoonnummer van de contactpersoon of de organisatie", - "title": "Telefoonnummer", - "type": "string", - "visible": true, - "facetable": false, - "order": 14, - "minLength": null, - "maxLength": null, - "example": "06 12345678", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "website": { - "description": "URL van de website van de organisatie", - "title": "Website", - "type": "string", - "required": true, - "visible": true, - "facetable": false, - "order": 16, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "status": { - "description": "Geeft aan of de VNG de organisatie positief beoordeeld heeft voor toegang tot de Softwarecatalogus", - "title": "Status", - "type": "string", - "default": "concept", - "visible": true, - "facetable": false, - "order": 13, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Concept", - "Actief", - "Deactief" - ] - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-07-29T09:35:54+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "type" - } - }, - "voorzieninggebruik": { - "uri": null, - "slug": "voorzieninggebruik", - "title": "Gebruik", - "description": "Een organisatie die een voorziening gebruikt", - "version": "0.0.19", - "summary": "", - "icon": null, - "required": [ - "organisatie", - "voorziening", - "status" - ], - "properties": { - "organisatie": { - "type": "object", - "title": "Organisatie", - "description": "De organisatie die de voorziening gebruikt", - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organisatie", - "required": true, - "order": 11 - }, - "voorziening": { - "type": "object", - "title": "Voorziening", - "description": "De voorziening die gebruikt wordt", - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/voorziening", - "required": true, - "order": 19 - }, - "versieId": { - "type": "string", - "format": "uuid", - "description": "ID van de specifieke versie van de voorziening die gebruikt wordt", - "facetable": false, - "title": "VersieId", - "order": 18 - }, - "beheerder": { - "type": "object", - "properties": { - "naam": { - "type": "string", - "description": "De volledige naam van de persoon" - }, - "email": { - "type": "string", - "format": "email", - "description": "Het e-mailadres van de persoon" - }, - "telefoonnummer": { - "type": "string", - "description": "Het telefoonnummer van de persoon" - }, - "functie": { - "type": "string", - "description": "De functie van de persoon" - } - }, - "description": "De contactpersoon voor dit gebruik van de voorziening", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "title": "Beheerder", - "order": 3 - }, - "startDatum": { - "type": "string", - "format": "date", - "description": "De datum waarop het gebruik van de voorziening is gestart", - "facetable": false, - "title": "StartDatum", - "order": 13 - }, - "bivClassificatie": { - "type": "object", - "properties": { - "beschikbaarheid": { - "type": "string", - "enum": [ - "Laag", - "Midden", - "Hoog" - ] - }, - "integriteit": { - "type": "string", - "enum": [ - "Laag", - "Midden", - "Hoog" - ] - }, - "vertrouwelijkheid": { - "type": "string", - "enum": [ - "Laag", - "Midden", - "Hoog" - ] - } - }, - "description": "De BIV (Beschikbaarheid, Integriteit, Vertrouwelijkheid) classificatie", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "title": "BivClassificatie", - "order": 4 - }, - "bedrijfsKritisch": { - "type": "boolean", - "description": "Geeft aan of het voorzieninggebruik bedrijfskritisch is", - "facetable": false, - "title": "BedrijfsKritisch", - "order": 2 - }, - "privacyGevoelig": { - "type": "boolean", - "description": "Geeft aan of het voorzieninggebruik privacy gevoelige gegevens bevat", - "facetable": false, - "title": "PrivacyGevoelig", - "order": 12 - }, - "hosting": { - "description": "", - "type": "string", - "default": "none", - "minLength": null, - "maxLength": null, - "example": "none", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Hosting", - "order": 8 - }, - "deelneming": { - "description": "De deelneming die dit gebruik heeft aangemaakt, indien ingevuld dan is dit gebruik aangemaakt door een andere organisatie dan de eigenaar", - "type": "string", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Deelneming", - "order": 6 - }, - "contact": { - "description": "", - "type": "string", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Contact", - "order": 5 - }, - "ibpScore": { - "description": "De IBP (Informatiebeveiliging en Privacy) score van het voorzieninggebruik", - "type": "integer", - "hideOnCollection": true, - "order": 9, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "exclusiveMin": 0, - "exclusiveMax": 0, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "IbpScore" - }, - "bbnScore": { - "description": "De BBN (Basis Beveiligings Niveau) score van het voorzieninggebruik", - "type": "integer", - "hideOnCollection": true, - "order": 1, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "exclusiveMin": 0, - "exclusiveMax": 0, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "BbnScore" - }, - "startDatumActief": { - "description": "De start datum voor het \"actief\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 14, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "StartDatumActief" - }, - "startDatumGepland": { - "description": "De start datum voor het \"Gepland\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 16, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "StartDatumGepland" - }, - "startDatumBe\u00ebindigd": { - "description": "De start datum voor het \"Be\u00ebindigd\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 15, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "StartDatumBe\u00ebindigd" - }, - "status": { - "description": "De status van het gebruik", - "type": "string", - "default": "concept", - "required": true, - "visible": true, - "order": 17, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Actief", - "Gepland", - "Be\u00ebindigd" - ], - "facetable": false, - "title": "Status" - }, - "eigenaar": { - "description": "", - "type": "object", - "visible": true, - "order": 7, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Eigenaar" - }, - "interneAantekening": { - "description": "Aanvullende interne informatie over het gebruik van de voorziening", - "type": "string", - "visible": true, - "hideOnCollection": true, - "order": 10, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "InterneAantekening" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-07-29T09:35:54+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "organisatie", - "objectDescriptionField": "status" - } - }, - "contract": { - "uri": null, - "slug": "contract", - "title": "Contract", - "description": "Een formele overeenkomst voor het inzetten van een VoorzieningAanbod op een VoorzieningGebruik", - "version": "0.0.4", - "summary": "", - "icon": null, - "required": [ - "voorzieningAanbod", - "voorzieningGebruik", - "startDatum", - "contractNummer", - "contractType", - "status" - ], - "properties": { - "voorzieningAanbod": { - "type": "object", - "properties": { - "naam": { - "type": "string" - } - }, - "description": "Het aanbod van de voorziening waarop dit contract betrekking heeft", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "required": true, - "title": "VoorzieningAanbod", - "order": 12 - }, - "voorzieningGebruik": { - "type": "object", - "properties": { - "organisatie": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "naam": { - "type": "string" - } - } - }, - "voorziening": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "naam": { - "type": "string" - } - } - } - }, - "description": "Het gebruik van de voorziening waarop dit contract betrekking heeft", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "required": true, - "title": "VoorzieningGebruik", - "order": 13 - }, - "startDatum": { - "type": "string", - "format": "date", - "description": "De startdatum van het contract", - "facetable": false, - "required": true, - "title": "StartDatum", - "order": 10 - }, - "eindDatum": { - "type": "string", - "format": "date", - "description": "De einddatum van het contract (indien van toepassing)", - "facetable": false, - "title": "EindDatum", - "order": 6 - }, - "contractNummer": { - "type": "string", - "description": "Het referentienummer van het contract", - "facetable": false, - "required": true, - "title": "ContractNummer", - "order": 3 - }, - "contractType": { - "type": "string", - "enum": [ - "SLA", - "Licentie", - "Onderhoud" - ], - "description": "Het type contract", - "facetable": false, - "required": true, - "title": "ContractType", - "order": 4 - }, - "kosten": { - "type": "number", - "description": "De kosten verbonden aan het contract", - "facetable": false, - "title": "Kosten", - "order": 7 - }, - "kostenPeriode": { - "type": "string", - "enum": [ - "Maandelijks", - "Jaarlijks", - "Eenmalig" - ], - "description": "De periode waarop de kosten betrekking hebben", - "facetable": false, - "title": "KostenPeriode", - "order": 8 - }, - "contactpersoonAanbieder": { - "type": "object", - "properties": { - "naam": { - "type": "string" - }, - "email": { - "type": "string" - } - }, - "description": "De contactpersoon bij de aanbieder", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "title": "ContactpersoonAanbieder", - "order": 1 - }, - "contactpersoonGebruiker": { - "type": "object", - "properties": { - "naam": { - "type": "string" - }, - "email": { - "type": "string" - } - }, - "description": "De contactpersoon bij de gebruiker", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "title": "ContactpersoonGebruiker", - "order": 2 - }, - "documentReferentie": { - "type": "string", - "description": "Referentie naar het contractdocument", - "facetable": false, - "title": "DocumentReferentie", - "order": 5 - }, - "status": { - "type": "string", - "enum": [ - "Actief", - "Verlopen", - "In onderhandeling" - ], - "description": "De status van het contract", - "facetable": false, - "required": true, - "title": "Status", - "order": 11 - }, - "opmerkingen": { - "type": "string", - "description": "Aanvullende informatie over het contract", - "facetable": false, - "title": "Opmerkingen", - "order": 9 - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-05-09T12:14:18+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "contractNummer", - "objectDescriptionField": "contractType" - } - }, - "standaard": { - "uri": null, - "slug": "standaard", - "title": "Standaard", - "description": "Een specifieke standaard waaraan een voorziening kan voldoen", - "version": "0.0.3", - "summary": "", - "icon": null, - "required": [ - "naam" - ], - "properties": { - "naam": { - "type": "string", - "description": "Naam van de standaard", - "facetable": false, - "required": true, - "title": "Naam", - "order": 3 - }, - "beschrijving": { - "type": "string", - "description": "Beschrijving van de standaard", - "facetable": false, - "title": "Beschrijving", - "order": 2 - }, - "standaardtype": { - "type": "string", - "enum": [ - "Type1", - "Type2", - "Type3" - ], - "description": "Type van de standaard", - "facetable": false, - "title": "Standaardtype", - "order": 4 - }, - "versie": { - "type": "string", - "description": "Versie van de standaard", - "facetable": false, - "title": "Versie", - "order": 6 - }, - "beheerorganisatie": { - "type": "string", - "description": "Organisatie die de standaard beheert", - "facetable": false, - "title": "Beheerorganisatie", - "order": 1 - }, - "website": { - "type": "string", - "format": "uri", - "description": "Website van de standaard", - "facetable": false, - "title": "Website", - "order": 7 - }, - "status": { - "type": "string", - "enum": [ - "Concept", - "In ontwikkeling", - "Vastgesteld", - "Verouderd" - ], - "description": "Status van de standaard", - "facetable": false, - "title": "Status", - "order": 5 - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-05-09T12:14:18+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "beschrijving" - } - }, - "review": { - "uri": null, - "slug": "review", - "title": "Review", - "description": "Beoordeling van een voorziening", - "version": "0.0.3", - "summary": "", - "icon": null, - "required": [ - "voorziening", - "score" - ], - "properties": { - "voorziening": { - "$ref": "#/components/schemas/Voorziening", - "description": "De beoordeelde voorziening", - "type": "string", - "facetable": false, - "required": true, - "title": "Voorziening", - "order": 7 - }, - "score": { - "type": "number", - "minimum": 1, - "maximum": 5, - "description": "Score van de beoordeling (1-5)", - "facetable": false, - "required": true, - "title": "Score", - "order": 3 - }, - "titel": { - "type": "string", - "description": "Titel van de beoordeling", - "facetable": false, - "title": "Titel", - "order": 5 - }, - "tekst": { - "type": "string", - "description": "Tekst van de beoordeling", - "facetable": false, - "title": "Tekst", - "order": 4 - }, - "datum": { - "type": "string", - "format": "date", - "description": "Datum van de beoordeling", - "facetable": false, - "title": "Datum", - "order": 1 - }, - "voordelen": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Voordelen van de voorziening", - "facetable": false, - "title": "Voordelen", - "order": 6 - }, - "nadelen": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Nadelen van de voorziening", - "facetable": false, - "title": "Nadelen", - "order": 2 - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-05-09T12:14:18+00:00", - "created": "2025-05-09T12:14:18+00:00", - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "titel", - "objectDescriptionField": "tekst" - } - }, - "koppeling": { - "uri": null, - "slug": "koppeling", - "title": "Koppeling", - "description": "Schema voor koppelingen tussen modules en systemen", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [], - "properties": { - "type": { - "description": "", - "type": "string", - "order": 1, - "title": "Type" - }, - "aantalGebruik": { - "description": "", - "type": "number", - "order": 2, - "title": "AantalGebruik" - }, - "status": { - "description": "", - "type": "string", - "order": 3, - "title": "Status" - }, - "datumStatus": { - "description": "", - "type": "string", - "order": 4, - "title": "DatumStatus" - }, - "beschrijvingKort": { - "description": "", - "type": "string", - "order": 5, - "title": "BeschrijvingKort" - }, - "beschrijvingLang": { - "description": "", - "type": "string", - "order": 6, - "title": "BeschrijvingLang" - }, - "gegevensuitwisselingRichting": { - "description": "", - "type": "string", - "order": 7, - "title": "GegevensuitwisselingRichting" - }, - "vanModule": { - "description": "", - "type": "string", - "order": 8, - "title": "VanModule" - }, - "naarModule": { - "description": "", - "type": "string", - "order": 9, - "title": "NaarModule" - }, - "buitengemeentelijkVoorziening": { - "description": "", - "type": "string", - "order": 10, - "title": "BuitengemeentelijkVoorziening" - }, - "implementeertStandaardversie": { - "description": "", - "type": "string", - "order": 11, - "title": "ImplementeertStandaardversie" - }, - "gerealiseerdMetIntermediairModule": { - "description": "", - "type": "string", - "order": 12, - "title": "GerealiseerdMetIntermediairModule" - }, - "gegevensuitwisselingVanBedrijfsobjecten": { - "description": "", - "type": "string", - "order": 13, - "title": "GegevensuitwisselingVanBedrijfsobjecten" - }, - "organisatieRegistreertPlaatsvervangend": { - "description": "", - "type": "string", - "order": 14, - "title": "OrganisatieRegistreertPlaatsvervangend" - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-08-08T07:11:40+00:00", - "created": "2025-08-08T07:11:40+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "type", - "objectDescriptionField": "beschrijvingKort" - } - }, - "beoordeeling": { - "uri": null, - "slug": "beoordeeling", - "title": "Beoordeeling", - "description": "", - "version": "0.0.9", - "summary": "", - "icon": null, - "required": [], - "properties": { - "titel": { - "description": "", - "type": "string", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Titel", - "order": 4 - }, - "beschrijving": { - "description": "", - "type": "string", - "visible": true, - "order": 2, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Beschrijving" - }, - "waardering": { - "description": "", - "type": "integer", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": 5, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Waardering", - "order": 6 - }, - "voorziening": { - "description": "", - "type": "string", - "format": "uuid", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Voorziening", - "order": 5 - }, - "aanbod": { - "description": "", - "type": "string", - "format": "uuid", - "visible": true, - "order": 1, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Aanbod" - }, - "gebruik": { - "description": "", - "type": "string", - "format": "uuid", - "visible": true, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Gebruik", - "order": 3 - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-05-12T20:01:42+00:00", - "created": "2025-05-12T19:58:51+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "titel", - "objectDescriptionField": "beschrijving" - } - }, - "element": { - "slug": "element", - "title": "Element", - "description": "AMEF Element", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [ - "identifier", - "type", - "properties" - ], - "properties": { - "identifier": { - "description": "De identifier van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-009fa62f25844aa3a87d252bf2b6bb0c", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "type": { - "description": "Het type van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Capability", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name": { - "description": "De naam van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Publiceren en gebruiken van informatie over datadiensten", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name-lang": { - "description": "De name-language van dit Element", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation": { - "description": "De documentation van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Dienstenafnemers moeten in online catalogi kunnen opvragen welke diensten, met welke kenmerken, door dienstenaanbieder worden aangeboden. \\nOnder andere ontwikkelaars hebben baat bij informatie over beschikbare diensten en de vereisten voor het gebruik van de dienst (bijv. specificatie van een dienst conform de OAS-standaard).", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation-lang": { - "description": "De documentation-language van dit Element", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "properties": { - "description": "De properties van dit Element", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json/19", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-04-01T12:16:33+00:00", - "created": "2025-03-03T13:56:42+00:00", - "maxDepth": 4, - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "view": { - "slug": "view", - "title": "View", - "description": "AMEF View", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [ - "identifier", - "type", - "name", - "properties", - "nodes", - "connections" - ], - "properties": { - "identifier": { - "description": "De identifier van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-a6ee6077d3094afa91fc6ea92a9a2a40", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "type": { - "description": "De type van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Diagram", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "viewpoint": { - "description": "De viewpoint van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Application Structure", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name": { - "description": "De name van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "LV01 BGT basisregistratie en SVB view", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name-lang": { - "description": "De name-language van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation": { - "description": "De documentation van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Toont de referentiecomponenten ter ondersteuning van applicatieservices voor publieksdiensten", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation-lang": { - "description": "De documentation-language van deze View", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "properties": { - "description": "De properties van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "nodes": { - "description": "De nodes van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View_Node.json", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "connections": { - "description": "De connections van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View_Connection.json", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-03-25T16:30:49+00:00", - "created": "2025-03-03T13:56:48+00:00", - "maxDepth": 0, - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "model": { - "slug": "model", - "title": "Model", - "description": "This object represents an entire AMEF collection", - "version": "0.0.37", - "summary": "", - "icon": null, - "required": [ - "xmlns", - "xsi", - "schemaLocation", - "identifier", - "name", - "name-lang", - "version", - "documentation", - "documentation-lang", - "properties", - "elements", - "relationships", - "organizations", - "propertyDefinitions", - "views" - ], - "properties": { - "xmlns": { - "description": "De xmlns van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "http://www.opengroup.org/xsd/archimate/3.0/", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "xsi": { - "description": "De xsi van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "http://www.w3.org/2001/XMLSchema-instance", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "schemaLocation": { - "description": "De schemaLocation van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "http://www.opengroup.org/xsd/archimate/3.0/ http://www.opengroup.org/xsd/archimate/3.1/archimate3_Diagram.xsd", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "identifier": { - "description": "De identifier van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-b58b6b03-a59d-472b-bd87-88ba77ded4e6", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name": { - "description": "De name van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "GEMMA release (test)", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name-lang": { - "description": "De name-language van dit GEMMA Model", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "version": { - "description": "De version van dit GEMMA Model", - "type": "string", - "minLength": 3, - "maxLength": null, - "example": "3.0", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation": { - "description": "De documentation van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "De GEMeentelijk Model Architectuur (GEMMA) bevat een blauwdruk van de gemeente en haar informatievoorziening. De GEMMA kan worden gebruikt als basis voor de projectmodellen", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation-lang": { - "description": "De documentation-language van dit GEMMA Model", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "properties": { - "description": "De properties van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json/19", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "elements": { - "description": "De elements van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Element.json/10", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "relationships": { - "description": "De relationships van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Relation.json/13", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "organizations": { - "description": "De organizations van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "gebruiker", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "propertyDefinitions": { - "description": "De propertyDefinitions van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Property_Definition.json/15", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "views": { - "description": "De views van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View.json/11", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-04-01T15:12:59+00:00", - "created": "2025-03-03T13:57:16+00:00", - "maxDepth": 4, - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "organization": { - "slug": "organization", - "title": "Organization", - "description": "AMEF Organization", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [], - "properties": { - "identifierRef": { - "description": "De identifierRef van deze Organization", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-009fa62f25844aa3a87d252bf2b6bb0c", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "label": { - "description": "De label van deze Organization", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Strategy", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "label-lang": { - "description": "De label-language van deze Organization", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation": { - "description": "De documentation van deze Organization", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Het Ondernemingsdossier stelt een ondernemer in staat om bepaalde informatie uit de bedrijfsvoering eenmalig vast te leggen en meerdere keren beschikbaar te stellen aan overheden zoals toezichthouders en vergunningverleners", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation-lang": { - "description": "De documentation-language van deze Organization", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "item": { - "description": "De items (Organizations) van deze Organization", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "gebruiker", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-04-03T09:28:37+00:00", - "created": "2025-03-03T13:56:55+00:00", - "maxDepth": 4, - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "property-definition": { - "slug": "property-definition", - "title": "Property Definition", - "description": "AMEF Property Definition", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [ - "identifier", - "type" - ], - "properties": { - "identifier": { - "description": "De identifier van deze Property Definition", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "propid-43", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "type": { - "description": "De type van deze Property Definition", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "string", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name": { - "description": "De name van deze Property Definition", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "API-portaal", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name-lang": { - "description": "De name-language van deze Property Definition", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-03-27T14:48:34+00:00", - "created": "2025-03-10T13:31:19+00:00", - "maxDepth": 0, - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "relation": { - "slug": "relation", - "title": "Relation", - "description": "AMEF Relation", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [ - "identifier", - "source", - "target", - "type", - "properties" - ], - "properties": { - "identifier": { - "description": "De identifier van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-1b46181d68e5477a9c0b5a95a0677924", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "source": { - "description": "De source van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-d143a1fc-02dc-11e6-11ba-005056a85f9c", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "target": { - "description": "De target van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-a85f22d89af14222a914fcb9ecfe6815", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "type": { - "description": "De type van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Access", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "accessType": { - "description": "De accessType van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Read", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "isDirected": { - "description": "De isDirected van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "true", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name": { - "description": "De name van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Verplicht", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name-lang": { - "description": "De name-language van deze Relation", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation": { - "description": "De documentation van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Op basis van het zaaktype routeert de servicebuscomponent de aanvraag naar een Zaakafhandelcomponent (generiek of specifiek).", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation-lang": { - "description": "De documentation-lang van deze Relation", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "properties": { - "description": "De properties van deze Relation", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json/19", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-04-01T08:47:45+00:00", - "created": "2025-03-03T13:57:01+00:00", - "maxDepth": 4, - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "extendview": { - "slug": "extendview", - "title": "Extended View", - "description": "AMEF view is pre extened copy of a view for performance reasons", - "version": "0.0.3", - "summary": "", - "icon": null, - "required": [ - "identifier", - "type", - "name", - "properties", - "nodes", - "connections" - ], - "properties": { - "identifier": { - "description": "De identifier van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-a6ee6077d3094afa91fc6ea92a9a2a40", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "type": { - "description": "De type van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Diagram", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "viewpoint": { - "description": "De viewpoint van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Application Structure", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name": { - "description": "De name van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "LV01 BGT basisregistratie en SVB view", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "name-lang": { - "description": "De name-language van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation": { - "description": "De documentation van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Toont de referentiecomponenten ter ondersteuning van applicatieservices voor publieksdiensten", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "documentation-lang": { - "description": "De documentation-language van deze View", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "properties": { - "description": "De properties van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "nodes": { - "description": "De nodes van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View_Node.json", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - }, - "connections": { - "description": "De connections van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View_Connection.json", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [] - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-05-15T10:54:50+00:00", - "created": "2025-05-13T19:49:15+00:00", - "maxDepth": 0, - "owner": null, - "application": null, - "organisation": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "voorzieningModule": { - "uri": null, - "slug": "voorzieningModule", - "title": "Module", - "description": "Een module is een onderdeel van een product (voorziening)", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [ - "naam" - ], - "properties": { - "naam": { - "type": "string", - "description": "Naam van de module", - "title": "Naam", - "order": 1, - "facetable": false, - "required": true - }, - "beschrijvingKort": { - "type": "string", - "description": "Korte beschrijving van de module", - "title": "Beschrijving kort", - "order": 2, - "facetable": false - }, - "beschrijvingLang": { - "type": "string", - "description": "Uitgebreide beschrijving van de module", - "title": "Beschrijving lang", - "order": 3, - "facetable": false - }, - "statusAanbod": { - "type": "string", - "description": "Life-cycle van de module", - "title": "Status aanbod", - "order": 4, - "facetable": false, - "enum": [ - "In ontwikkeling", - "In gebruik", - "Einde ondersteuning", - "Teruggetrokken" - ] - }, - "datumOntwikkeling": { - "type": "string", - "format": "date", - "description": "Start van de ontwikkeling van de module", - "title": "Datum ontwikkeling", - "order": 5, - "facetable": false - }, - "datumTest": { - "type": "string", - "format": "date", - "description": "Start testfase van de module", - "title": "Datum test", - "order": 6, - "facetable": false - }, - "datumDistributie": { - "type": "string", - "format": "date", - "description": "Datum vanaf wanneer module beschikbaar is voor gebruik", - "title": "Datum distributie", - "order": 7, - "facetable": false - }, - "datumEindeOndersteuning": { - "type": "string", - "format": "date", - "description": "Datum vanaf wanneer module niet meer ondersteund wordt", - "title": "Datum einde ondersteuning", - "order": 8, - "facetable": false - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-07-29T09:24:00+00:00", - "created": "2025-07-29T09:24:00+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "beschrijvingKort" - } - }, - "verklaring": { - "uri": null, - "slug": "verklaring", - "title": "Verklaring", - "description": "Een verklaring is een document of uitspraak die bevestigt of verklaart dat een product voldoet aan bepaalde eisen of standaarden", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [ - "naam", - "type" - ], - "properties": { - "naam": { - "type": "string", - "description": "Naam van de verklaring", - "title": "Naam", - "order": 1, - "facetable": true, - "required": true - }, - "type": { - "type": "string", - "description": "Type van de verklaring (bijv. certificaat, compliance, audit)", - "title": "Type", - "order": 2, - "facetable": true, - "required": true - }, - "link": { - "type": "string", - "format": "url", - "description": "Link naar het document of meer informatie over de verklaring", - "title": "Link", - "order": 3, - "facetable": false - }, - "toelichting": { - "type": "string", - "description": "Toelichting of uitleg over de verklaring", - "title": "Toelichting", - "order": 4, - "facetable": false - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "updated": "2025-07-29T12:00:00+00:00", - "created": "2025-07-29T12:00:00+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "naam", - "objectDescriptionField": "type" - } - }, - "koppelingGebruik": { - "uri": null, - "slug": "koppelingGebruik", - "title": "Koppeling Gebruik", - "description": "Schema voor het gebruik van koppelingen", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [], - "properties": { - "gebruikVanKoppeling": { - "description": "", - "type": "string", - "order": 1, - "title": "GebruikVanKoppeling" - }, - "status": { - "description": "", - "type": "string", - "order": 2, - "title": "Status" - }, - "datumStatus": { - "description": "", - "type": "string", - "order": 3, - "title": "DatumStatus" - }, - "beschrijvingKort": { - "description": "", - "type": "string", - "order": 4, - "title": "BeschrijvingKort" - }, - "beschrijvingLang": { - "description": "", - "type": "string", - "order": 5, - "title": "BeschrijvingLang" - }, - "interneAantekening": { - "description": "", - "type": "string", - "order": 6, - "title": "InterneAantekening" - }, - "BIVclassificatie": { - "description": "", - "type": "string", - "order": 7, - "title": "BIVclassificatie" - }, - "IBPrisicoanalyse": { - "description": "", - "type": "string", - "order": 8, - "title": "IBPrisicoanalyse" - }, - "isBedrijfskritisch": { - "description": "", - "type": "string", - "order": 9, - "title": "IsBedrijfskritisch" - }, - "isPrivacygevoelig": { - "description": "", - "type": "string", - "order": 10, - "title": "IsPrivacygevoelig" - }, - "organisatieRegistreertGebruik": { - "description": "", - "type": "string", - "order": 11, - "title": "OrganisatieRegistreertGebruik" - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-08-08T07:11:40+00:00", - "created": "2025-08-08T07:11:40+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "compliancy": { - "uri": null, - "slug": "compliancy", - "title": "Compliancy", - "description": "Schema voor compliancy en standaard ondersteuning", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [], - "properties": { - "ondersteuntStandaardversie": { - "description": "", - "type": "string", - "order": 1, - "title": "OndersteuntStandaardversie" - }, - "TestrapportLink": { - "description": "", - "type": "string", - "order": 2, - "title": "TestrapportLink" - }, - "ondersteuningIndicatie": { - "description": "", - "type": "boolean", - "order": 3, - "title": "OndersteuningIndicatie" - }, - "moduleIsCompliant": { - "description": "", - "type": "string", - "order": 4, - "title": "ModuleIsCompliant" - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-08-08T07:11:40+00:00", - "created": "2025-08-08T07:11:40+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "ondersteuntStandaardversie", - "objectDescriptionField": "moduleIsCompliant" - } - }, - "moduleGebruik": { - "uri": null, - "slug": "moduleGebruik", - "title": "Module Gebruik", - "description": "Schema voor het gebruik van modules", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [], - "properties": { - "gebruikVanModule": { - "description": "", - "type": "string", - "order": 1, - "title": "GebruikVanModule" - }, - "gebruikVanModuleversies": { - "description": "", - "type": "string", - "order": 2, - "title": "GebruikVanModuleversies" - }, - "gebruiktVoorReferentiecomponenten": { - "description": "", - "type": "string", - "order": 3, - "title": "GebruiktVoorReferentiecomponenten" - }, - "gebruiktVoorTechnologiecomponenten": { - "description": "", - "type": "string", - "order": 4, - "title": "GebruiktVoorTechnologiecomponenten" - }, - "gemeente_naam": { - "description": "", - "type": "string", - "order": 5, - "title": "Gemeente naam" - }, - "samenwerking": { - "description": "", - "type": "string", - "order": 6, - "title": "Samenwerking" - }, - "organisatieRegistreertGebruik": { - "description": "", - "type": "string", - "order": 7, - "title": "OrganisatieRegistreertGebruik" - }, - "organisatieDeelnemerGebruik": { - "description": "", - "type": "string", - "order": 8, - "title": "OrganisatieDeelnemerGebruik" - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-08-08T07:11:40+00:00", - "created": "2025-08-08T07:11:40+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": null - }, - "moduleversie": { - "uri": null, - "slug": "moduleversie", - "title": "Module Versie", - "description": "Schema voor module versies", - "version": "0.0.1", - "summary": "", - "icon": null, - "required": [], - "properties": { - "module": { - "description": "", - "type": "string", - "order": 1, - "title": "Module" - }, - "versie": { - "description": "", - "type": "number", - "order": 2, - "title": "Versie" - }, - "beschrijvingKort": { - "description": "", - "type": "string", - "order": 3, - "title": "BeschrijvingKort" - }, - "beschrijvingLang": { - "description": "", - "type": "string", - "order": 4, - "title": "BeschrijvingLang" - }, - "mutatiedatum_pakketversie": { - "description": "", - "type": "string", - "order": 5, - "title": "Mutatiedatum pakketversie" - }, - "pakketversie_status": { - "description": "", - "type": "string", - "order": 6, - "title": "Pakketversie status" - }, - "datumOntwikkeling_org": { - "description": "", - "type": "string", - "order": 7, - "title": "DatumOntwikkeling org" - }, - "datumTest_org": { - "description": "", - "type": "string", - "order": 8, - "title": "DatumTest org" - }, - "datumDistributie_org": { - "description": "", - "type": "string", - "order": 9, - "title": "DatumDistributie org" - }, - "datumEindeOndersteuning_org": { - "description": "", - "type": "string", - "order": 10, - "title": "DatumEindeOndersteuning org" - }, - "datumOntwikkeling": { - "description": "", - "type": "string", - "order": 11, - "title": "DatumOntwikkeling" - }, - "datumTest": { - "description": "", - "type": "string", - "order": 12, - "title": "DatumTest" - }, - "datumDistributie": { - "description": "", - "type": "string", - "order": 13, - "title": "DatumDistributie" - }, - "datumEindeOndersteuning": { - "description": "", - "type": "string", - "order": 14, - "title": "DatumEindeOndersteuning" - }, - "compliancy_indicated": { - "description": "", - "type": "boolean", - "order": 15, - "title": "Compliancy indicated" - }, - "testrapport_url": { - "description": "", - "type": "string", - "order": 16, - "title": "Testrapport url" - } - }, - "archive": [], - "source": "", - "hardValidation": false, - "updated": "2025-08-08T07:11:40+00:00", - "created": "2025-08-08T07:11:40+00:00", - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", - "groups": null, - "authorization": null, - "deleted": null, - "configuration": { - "objectNameField": "module", - "objectDescriptionField": "beschrijvingKort" - } + "deleted": null, + "configuration": { + "objectNameField": "afnemer", + "objectDescriptionField": "product", + "allowFiles": true, + "allowedTags": ["DPIA", "Contract", "Verwerkingsovereenkomst"] + } + }, + "contract": { + "uri": null, + "slug": "contract", + "title": "Contract", + "description": "Een formele overeenkomst voor het inzetten van een Dienst op een Gebruik", + "version": "0.0.7", + "summary": "", + "icon": "FileDocumentEdit", + "required": [ + "dienst", + "gebruik", + "startDatum", + "contractNummer", + "contractType", + "status" + ], + "properties": { + "dienst": { + "description": "De dienst waarop dit contract betrekking heeft", + "type": "object", + "facetable": false, + "required": true, + "title": "Dienst", + "order": 12, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/dienst" + }, + "gebruik": { + "description": "Het gebruik van de voorziening waarop dit contract betrekking heeft", + "type": "object", + "facetable": false, + "required": true, + "title": "Gebruik", + "order": 13, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/gebruik" + }, + "startDatum": { + "type": "string", + "format": "date", + "description": "De startdatum van het contract", + "facetable": false, + "required": true, + "title": "Startdatum", + "order": 10, + "example": "Bijvoorbeeld: 2025-01-01" + }, + "eindDatum": { + "type": "string", + "format": "date", + "description": "De einddatum van het contract (indien van toepassing)", + "facetable": false, + "title": "Einddatum", + "order": 6, + "example": "Bijvoorbeeld: 2025-12-31" + }, + "contractNummer": { + "type": "string", + "description": "Het referentienummer van het contract", + "facetable": false, + "required": true, + "title": "Contract Nummer", + "order": 3, + "example": "Bijvoorbeeld: CON-2025-001" + }, + "contractType": { + "type": "string", + "enum": ["SLA", "Licentie", "Onderhoud"], + "description": "Het type contract", + "facetable": false, + "required": true, + "title": "Contract Type", + "order": 4, + "example": "Bijvoorbeeld: SLA" + }, + "kosten": { + "type": "number", + "description": "De kosten verbonden aan het contract", + "facetable": false, + "title": "Kosten", + "order": 7, + "example": "Bijvoorbeeld: 1000.00" + }, + "kostenPeriode": { + "type": "string", + "enum": ["Maandelijks", "Jaarlijks", "Eenmalig"], + "description": "De periode waarop de kosten betrekking hebben", + "facetable": false, + "title": "Kosten Periode", + "order": 8, + "example": "Bijvoorbeeld: Jaarlijks" + }, + "contactpersoonAanbieder": { + "type": "object", + "properties": { + "naam": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "description": "De contactpersoon bij de aanbieder", + "facetable": false, + "objectConfiguration": { + "handling": "nested-object" + }, + "title": "Contactpersoon Aanbieder", + "order": 1 + }, + "contactpersoonGebruiker": { + "type": "object", + "properties": { + "naam": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "description": "De contactpersoon bij de gebruiker", + "facetable": false, + "objectConfiguration": { + "handling": "nested-object" + }, + "title": "Contactpersoon Gebruiker", + "order": 2 + }, + "documentReferentie": { + "type": "string", + "description": "Referentie naar het contractdocument", + "facetable": false, + "title": "Document Referentie", + "order": 5, + "example": "Bijvoorbeeld: CON-2025-001.pdf" + }, + "status": { + "type": "string", + "enum": ["Actief", "Verlopen", "In onderhandeling"], + "description": "De status van het contract", + "facetable": false, + "required": true, + "title": "Status", + "order": 11, + "example": "Bijvoorbeeld: Actief" + }, + "opmerkingen": { + "type": "string", + "description": "Aanvullende informatie over het contract", + "facetable": false, + "title": "Remarks", + "order": 9, + "example": "Bijvoorbeeld: Aanvullende opmerkingen over het contract" + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-05-09T12:14:18+00:00", + "created": "2025-05-09T12:14:18+00:00", + "maxDepth": 0, + "owner": "1", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": { + "create": [ + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar", + "aanbod-beheerder" + ], + "read": [ + "public", + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "gebruik-raadpleger" + ], + "update": [ + "aanbod-beheerder", + "ambtenaar", + "functioneel-beheerder", + "gebruik-beheerder", + "gebruik-raadpleger", + "organisatie-beheerder", + "organisaties-beheerder", + "software-catalog-admins", + "software-catalog-users", + "vng-raadpleger" + ], + "delete": [ + "aanbod-beheerder", + "vng-raadpleger", + "software-catalog-users", + "software-catalog-admins", + "organisaties-beheerder", + "organisatie-beheerder", + "gebruik-raadpleger", + "gebruik-beheerder", + "functioneel-beheerder", + "ambtenaar" + ] + }, + "deleted": null, + "configuration": { + "objectNameField": "contractNummer", + "objectDescriptionField": "contractType" + } + }, + "koppeling": { + "uri": null, + "slug": "koppeling", + "title": "Koppeling", + "description": "Schema voor koppelingen tussen modules en systemen. Er moet óf ModuleB óf buitengemeentelijkVoorziening gevuld zijn.", + "version": "0.0.4", + "summary": "", + "icon": "Link", + "required": [], + "properties": { + "naam": { + "description": "Naam van de koppeling", + "type": "string", + "order": 1, + "title": "Naam", + "example": "Bijvoorbeeld: API Koppeling" + }, + "beschrijvingKort": { + "description": "Korte beschrijving van de koppeling", + "type": "string", + "order": 5, + "title": "Samenvatting", + "example": "Bijvoorbeeld: Korte beschrijving van de koppeling" + }, + "beschrijvingLang": { + "description": "Uitgebreide beschrijving van de koppeling", + "type": "string", + "format": "markdown", + "order": 6, + "title": "Beschrijving", + "example": "Bijvoorbeeld: Uitgebreide beschrijving van de koppeling" + }, + "type": { + "description": "Het type koppeling, bijvoorbeeld 'bestandsoverdracht', 'digikoppeling', of 'api'.", + "type": "string", + "order": 1, + "title": "Type", + "enum": [ + "n.v.t.", + "bestandsoverdracht", + "digikoppeling", + "message que", + "upload naar portaal", + "webservices", + "api" + ], + "example": "Bijvoorbeeld: api" + }, + "status": { + "description": "De status van de koppeling", + "type": "string", + "order": 3, + "title": "Status", + "enum": [ + "in ontwikkeling", + "in gebruik", + "einde ondersteuning", + "teruggetrokken" + ] + }, + "datumInOntwikkeling": { + "description": "Startdatum van de ontwikkelingsfase", + "type": "string", + "format": "date", + "order": 4, + "title": "Datum In Ontwikkeling", + "example": "Bijvoorbeeld: 2025-01-01" + }, + "datumInGebruik": { + "description": "Startdatum van gebruik", + "type": "string", + "format": "date", + "order": 5, + "title": "Datum In Gebruik" + }, + "datumEindeOndersteuning": { + "description": "Startdatum einde ondersteuning", + "type": "string", + "format": "date", + "order": 6, + "title": "Datum Einde Ondersteuning" + }, + "datumTeruggetrokken": { + "description": "Datum waarop de koppeling teruggetrokken is", + "type": "string", + "format": "date", + "order": 7, + "title": "Datum Teruggetrokken" + }, + "gegevensuitwisselingRichting": { + "description": "De richting van de gegevensuitwisseling", + "type": "string", + "order": 8, + "title": "Gegevensuitwisseling Richting", + "enum": ["AnaarB", "BnaarA", "bi-directioneel"] + }, + "moduleA": { + "description": "De module waarvan de gegevens uitgewisseld worden", + "type": "object", + "order": 9, + "title": "Module A", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "koppeling" + }, + "moduleB": { + "description": "De module waarnaar de gegevens uitgewisseld worden", + "type": "object", + "order": 10, + "title": "Module B", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module" + }, + "buitengemeentelijkVoorziening": { + "description": "Buitengemeentelijke voorziening waarmee gekoppeld wordt", + "type": "object", + "order": 11, + "title": "Buitengemeentelijke Voorziening", + "objectConfiguration": { + "handling": "related-object", + "queryParams": "gemmaType=Buitengemeentenlijke voorziening" + }, + "$ref": "#/components/schemas/element" + }, + "standaardversies": { + "description": "Standaardversies die door deze koppeling geïmplementeerd worden", + "type": "array", + "order": 12, + "title": "Standaard Versies", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object", + "queryParams": "gemmaType=standaardversie" + }, + "$ref": "#/components/schemas/element" + } + }, + "gerealiseerdMetIntermediairModule": { + "description": "Intermediaire module die wordt gebruikt voor de realisatie van deze koppeling", + "type": "object", + "order": 13, + "title": "Gerealiseerd Met Intermediair Module", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "koppeling" + }, + "aanbieder": { + "description": "De aanbieder van deze koppeling", + "type": "object", + "visible": true, + "order": 14, + "facetable": false, + "title": "Aanbieder", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/organisatie" + }, + "dienst": { + "description": "De dienst die deze koppeling gebruikt", + "type": "object", + "visible": true, + "order": 15, + "facetable": false, + "title": "Dienst", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/dienst", + "inversedBy": "koppelingen" + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-08-08T07:11:40+00:00", + "created": "2025-08-08T07:11:40+00:00", + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": null, + "deleted": null, + "configuration": { + "objectNameField": "type", + "objectDescriptionField": "beschrijvingKort", + "autoPublish": true + } + }, + "beoordeeling": { + "uri": null, + "slug": "beoordeeling", + "title": "Beoordeeling", + "description": "Schema voor beoordelingen en waarderingen van producten, modules en diensten", + "version": "0.0.12", + "summary": "", + "icon": "Star", + "required": ["naam", "waardering", "product"], + "properties": { + "naam": { + "description": "Naam van de beoordeling", + "type": "string", + "visible": true, + "required": true, + "facetable": false, + "title": "Naam", + "order": 1 + }, + "beschrijvingKort": { + "description": "Korte beschrijving van de beoordeling", + "type": "string", + "maxLength": 255, + "visible": true, + "facetable": false, + "title": "Samenvatting", + "order": 2 + }, + "beschrijvingLang": { + "description": "Uitgebreide beschrijving van de beoordeling", + "type": "string", + "format": "markdown", + "visible": true, + "facetable": false, + "title": "Beschrijving", + "order": 3 + }, + "waardering": { + "description": "Waardering van 1 tot en met 10", + "type": "integer", + "minimum": 1, + "maximum": 10, + "visible": true, + "required": true, + "facetable": true, + "title": "Waardering", + "order": 4 + }, + "product": { + "description": "Het product dat beoordeeld wordt", + "type": "object", + "visible": true, + "required": true, + "facetable": false, + "title": "Product", + "order": 5, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/product" + }, + "modules": { + "description": "Optioneel: specifieke modules die beoordeeld worden", + "type": "array", + "visible": true, + "facetable": false, + "title": "Modules", + "order": 6, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "beoordeeling" + } + }, + "diensten": { + "description": "Optioneel: specifieke diensten die beoordeeld worden", + "type": "array", + "visible": true, + "facetable": false, + "title": "Diensten", + "order": 7, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/dienst" + } + }, + "koppelingen": { + "description": "Optioneel: specifieke koppelingen die beoordeeld worden", + "type": "array", + "visible": true, + "facetable": false, + "title": "Koppelingen", + "order": 8, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/koppeling" + } + }, + "gebruik": { + "description": "Optioneel: het specifieke gebruik dat beoordeeld wordt", + "type": "object", + "visible": true, + "facetable": false, + "title": "Gebruik", + "order": 9, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/gebruik" + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-05-12T20:01:42+00:00", + "created": "2025-05-12T19:58:51+00:00", + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": null, + "deleted": null, + "configuration": { + "objectNameField": "naam", + "objectSummaryField": "beschrijvingKort", + "objectDescriptionField": "beschrijvingLang" + } + }, + "element": { + "slug": "element", + "title": "Element", + "description": "AMEF Element - Architectuur elementen uit het ArchiMate model", + "version": "0.0.3", + "summary": "", + "icon": "Cube", + "required": ["identifier", "type", "properties"], + "properties": { + "identifier": { + "description": "De identifier van dit Element", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "id-009fa62f25844aa3a87d252bf2b6bb0c", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "type": { + "description": "Het type van dit Element", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Capability", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name": { + "description": "De naam van dit Element", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Publiceren en gebruiken van informatie over datadiensten", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name-lang": { + "description": "De name-language van dit Element", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation": { + "description": "De documentation van dit Element", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Dienstenafnemers moeten in online catalogi kunnen opvragen welke diensten, met welke kenmerken, door dienstenaanbieder worden aangeboden. \\nOnder andere ontwikkelaars hebben baat bij informatie over beschikbare diensten en de vereisten voor het gebruik van de dienst (bijv. specificatie van een dienst conform de OAS-standaard).", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation-lang": { + "description": "De documentation-language van dit Element", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "properties": { + "description": "De properties van dit Element", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json/19", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-04-01T12:16:33+00:00", + "created": "2025-03-03T13:56:42+00:00", + "maxDepth": 4, + "owner": null, + "application": null, + "organisation": null, + "authorization": null, + "deleted": null, + "configuration": { + "objectNameField": "name", + "objectSummaryField": "summary" + } + }, + "view": { + "slug": "view", + "title": "View", + "description": "AMEF View - Architectuur views en diagrammen uit het ArchiMate model", + "version": "0.0.3", + "summary": "", + "icon": "Eye", + "required": [ + "identifier", + "type", + "name", + "properties", + "nodes", + "connections" + ], + "properties": { + "identifier": { + "description": "De identifier van deze View", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "id-a6ee6077d3094afa91fc6ea92a9a2a40", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "type": { + "description": "De type van deze View", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Diagram", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "viewpoint": { + "description": "De viewpoint van deze View", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Application Structure", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name": { + "description": "De name van deze View", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "LV01 BGT basisregistratie en SVB view", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name-lang": { + "description": "De name-language van deze View", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation": { + "description": "De documentation van deze View", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Toont de referentiecomponenten ter ondersteuning van applicatieservices voor publieksdiensten", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation-lang": { + "description": "De documentation-language van deze View", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "properties": { + "description": "De properties van deze View", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "nodes": { + "description": "De nodes van deze View", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View_Node.json", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "connections": { + "description": "De connections van deze View", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View_Connection.json", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-03-25T16:30:49+00:00", + "created": "2025-03-03T13:56:48+00:00", + "maxDepth": 0, + "owner": null, + "application": null, + "organisation": null, + "authorization": null, + "deleted": null, + "configuration": null + }, + "model": { + "slug": "model", + "title": "Model", + "description": "AMEF Model - Volledig ArchiMate model met alle elementen, relaties en views", + "version": "0.0.39", + "summary": "", + "icon": "Database", + "required": [ + "xmlns", + "xsi", + "schemaLocation", + "identifier", + "name", + "name-lang", + "version", + "documentation", + "documentation-lang", + "properties", + "elements", + "relationships", + "organizations", + "propertyDefinitions", + "views" + ], + "properties": { + "xmlns": { + "description": "De xmlns van dit GEMMA Model", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "http://www.opengroup.org/xsd/archimate/3.0/", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "xsi": { + "description": "De xsi van dit GEMMA Model", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "http://www.w3.org/2001/XMLSchema-instance", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "schemaLocation": { + "description": "De schemaLocation van dit GEMMA Model", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "http://www.opengroup.org/xsd/archimate/3.0/ http://www.opengroup.org/xsd/archimate/3.1/archimate3_Diagram.xsd", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "identifier": { + "description": "De identifier van dit GEMMA Model", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "id-b58b6b03-a59d-472b-bd87-88ba77ded4e6", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name": { + "description": "De name van dit GEMMA Model", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "GEMMA release (test)", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name-lang": { + "description": "De name-language van dit GEMMA Model", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "version": { + "description": "De version van dit GEMMA Model", + "type": "string", + "minLength": 3, + "maxLength": null, + "example": "3.0", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation": { + "description": "De documentation van dit GEMMA Model", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "De GEMeentelijk Model Architectuur (GEMMA) bevat een blauwdruk van de gemeente en haar informatievoorziening. De GEMMA kan worden gebruikt als basis voor de projectmodellen", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation-lang": { + "description": "De documentation-language van dit GEMMA Model", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "properties": { + "description": "De properties van dit GEMMA Model", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": 1, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json/19", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "elements": { + "description": "De elements van dit GEMMA Model", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": 1, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Element.json/10", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "relationships": { + "description": "De relationships van dit GEMMA Model", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": 1, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Relation.json/13", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "organizations": { + "description": "De organizations van dit GEMMA Model", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": 1, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "gebruiker", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "propertyDefinitions": { + "description": "De propertyDefinitions van dit GEMMA Model", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": 1, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Property_Definition.json/15", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "views": { + "description": "De views van dit GEMMA Model", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": 1, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/View.json/11", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-04-01T15:12:59+00:00", + "created": "2025-03-03T13:57:16+00:00", + "maxDepth": 4, + "owner": null, + "application": null, + "organisation": null, + "authorization": null, + "deleted": null, + "configuration": null + }, + "organization": { + "slug": "organization", + "title": "Organization", + "description": "AMEF Organization - Organisatie structuren uit het ArchiMate model", + "version": "0.0.3", + "summary": "", + "icon": "Building", + "required": [], + "properties": { + "identifierRef": { + "description": "De identifierRef van deze Organization", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "id-009fa62f25844aa3a87d252bf2b6bb0c", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "label": { + "description": "De label van deze Organization", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Strategy", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "label-lang": { + "description": "De label-language van deze Organization", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation": { + "description": "De documentation van deze Organization", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Het Ondernemingsdossier stelt een ondernemer in staat om bepaalde informatie uit de bedrijfsvoering eenmalig vast te leggen en meerdere keren beschikbaar te stellen aan overheden zoals toezichthouders en vergunningverleners", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation-lang": { + "description": "De documentation-language van deze Organization", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "item": { + "description": "De items (Organizations) van deze Organization", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "gebruiker", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-04-03T09:28:37+00:00", + "created": "2025-03-03T13:56:55+00:00", + "maxDepth": 4, + "owner": null, + "application": null, + "organisation": null, + "authorization": null, + "deleted": null, + "configuration": null + }, + "property-definition": { + "slug": "property-definition", + "title": "Property Definition", + "description": "AMEF Property Definition - Definitie van eigenschappen voor ArchiMate elementen", + "version": "0.0.3", + "summary": "", + "icon": "Settings", + "required": ["identifier", "type"], + "properties": { + "identifier": { + "description": "De identifier van deze Property Definition", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "propid-43", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "type": { + "description": "De type van deze Property Definition", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "string", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name": { + "description": "De name van deze Property Definition", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "API-portaal", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name-lang": { + "description": "De name-language van deze Property Definition", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-03-27T14:48:34+00:00", + "created": "2025-03-10T13:31:19+00:00", + "maxDepth": 0, + "owner": null, + "application": null, + "organisation": null, + "authorization": null, + "deleted": null, + "configuration": null + }, + "relation": { + "slug": "relation", + "title": "Relation", + "description": "AMEF Relation - Relaties tussen architectuur elementen uit het ArchiMate model", + "version": "0.0.3", + "summary": "", + "icon": "ArrowRight", + "required": ["identifier", "source", "target", "type", "properties"], + "properties": { + "identifier": { + "description": "De identifier van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "id-1b46181d68e5477a9c0b5a95a0677924", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "source": { + "description": "De source van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "id-d143a1fc-02dc-11e6-11ba-005056a85f9c", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "target": { + "description": "De target van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "id-a85f22d89af14222a914fcb9ecfe6815", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "type": { + "description": "De type van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Access", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "accessType": { + "description": "De accessType van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Read", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "isDirected": { + "description": "De isDirected van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "true", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name": { + "description": "De name van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Verplicht", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "name-lang": { + "description": "De name-language van deze Relation", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation": { + "description": "De documentation van deze Relation", + "type": "string", + "minLength": null, + "maxLength": null, + "example": "Op basis van het zaaktype routeert de servicebuscomponent de aanvraag naar een Zaakafhandelcomponent (generiek of specifiek).", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "documentation-lang": { + "description": "De documentation-lang van deze Relation", + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "nl", + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + }, + "properties": { + "description": "De properties van deze Relation", + "type": "array", + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "$ref": "", + "items": { + "cascadeDelete": true, + "$ref": "https://vng.accept.commonground.nu/openregister/schemas/Model_Property.json/19", + "type": "object" + }, + "objectConfiguration": { + "handling": "nested-object", + "schema": "" + }, + "fileConfiguration": { + "handling": "ignore", + "allowedMimeTypes": [], + "location": "", + "maxSize": 0 + }, + "oneOf": [] + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-04-01T08:47:45+00:00", + "created": "2025-03-03T13:57:01+00:00", + "maxDepth": 4, + "owner": null, + "application": null, + "organisation": null, + "authorization": null, + "deleted": null, + "configuration": null + }, + + "module": { + "uri": null, + "slug": "module", + "title": "Module", + "description": "Een module is een onderdeel van een product (voorziening)", + "version": "0.0.4", + "summary": "", + "icon": "Package", + "required": ["naam", "product", "beschrijvingKort"], + "properties": { + "naam": { + "type": "string", + "description": "Naam van de module", + "title": "Naam", + "order": 1, + "facetable": false, + "required": true, + "maxLength": 200 + }, + "beschrijvingKort": { + "type": "string", + "description": "Korte beschrijving van de module", + "title": "Korte omschrijving", + "order": 2, + "facetable": false, + "maxLength": 255 + }, + "beschrijvingLang": { + "type": "string", + "description": "Uitgebreide beschrijving van de module", + "title": "Beschrijving", + "order": 3, + "facetable": false, + "format": "markdown", + "maxLength": 5000 + }, + "licentietype": { + "description": "Het type licentie van de voorziening (open source of closed source)", + "type": "string", + "visible": true, + "order": 9, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "default": "Closed source", + "enum": ["Closed source", "Open source"], + "objectConfiguration": {}, + "fileConfiguration": {}, + "oneOf": [], + "facetable": false, + "title": "Licentievorm" + }, + "licentie": { + "description": "De specifieke licentie van de voorziening, alleen opgeven indien Open Source", + "type": "string", + "visible": true, + "order": 10, + "minLength": null, + "maxLength": null, + "minimum": null, + "maximum": null, + "multipleOf": null, + "minItems": null, + "maxItems": null, + "inversedBy": "", + "$ref": "", + "objectConfiguration": {}, + "fileConfiguration": {}, + "oneOf": [], + "facetable": false, + "enum": [ + "MIT License", + "GNU General Public License (GPL)", + "Apache License 2.0", + "BSD Licentie (Berkeley Software Distribution)", + "European Union Public Licence (EUPL), versie 1.2" + ], + "licentie": "License" + }, + "referentieComponenten": { + "description": "GEMMA referentiecomponenten die de module implementeert", + "type": "array", + "visible": true, + "order": 9, + "facetable": true, + "title": "Referentie Componenten", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object", + "queryParams": "gemmaType=referentiecomponent&_extend=aanbevolenStandaarden,verplichteStandaarden" + }, + "$ref": "#/components/schemas/element" + } + }, + "type": { + "description": "Het type module zoals geregistreerd in de catalogus", + "type": "string", + "visible": true, + "order": 11, + "facetable": false, + "title": "Type", + "default": "Applicatie", + "enum": ["Applicatie", "Systeemsoftware"] + }, + "logo": { + "description": "URL naar het logo van de module", + "type": "string", + "format": "url", + "visible": true, + "order": 18, + "facetable": false, + "title": "Logo", + "maxLength": 500 + }, + "producten": { + "description": "De producten waarvan deze module onderdeel is", + "type": "array", + "visible": true, + "order": 19, + "facetable": false, + "title": "Producten", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/product", + "inversedBy": "modules" + } + }, + "diensten": { + "description": "De diensten waarvan deze module onderdeel is", + "type": "array", + "visible": true, + "order": 20, + "facetable": false, + "title": "Diensten", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/dienst", + "inversedBy": "modules" + } + }, + "koppelingen": { + "description": "De koppelingen waarbij deze module betrokken is", + "type": "array", + "visible": true, + "order": 21, + "facetable": false, + "title": "Koppelingen", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/koppeling", + "inversedBy": "moduleA" + } + }, + "compliancy": { + "description": "De standaarden waar deze module aan voldoet (compliance registraties)", + "type": "array", + "visible": true, + "order": 22, + "facetable": false, + "title": "Compliance", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/compliancy" + } + }, + "moduleVersies": { + "description": "De versies van deze module", + "type": "array", + "visible": true, + "order": 23, + "facetable": false, + "title": "Module Versies", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/moduleVersie" + } + }, + "gebruiken": { + "description": "Het gebruik van deze module", + "type": "array", + "visible": true, + "order": 24, + "facetable": false, + "title": "Gebruik", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/gebruik" + } + }, + "beoordelingen": { + "description": "De beoordelingen van deze module", + "type": "array", + "visible": true, + "order": 25, + "facetable": false, + "title": "Beoordelingen", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/beoordeeling" + } + }, + "kwetsbaarheden": { + "description": "De kwetsbaarheden die deze module treffen", + "type": "array", + "visible": true, + "order": 26, + "facetable": false, + "title": "Kwetsbaarheden", + "hideOnForm": true, + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/kwetsbaarheid" + } + } + }, + "archive": [], + "source": "internal", + "hardValidation": false, + "immutable": false, + "updated": "2025-07-29T09:24:00+00:00", + "created": "2025-07-29T09:24:00+00:00", + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": null, + "deleted": null, + "configuration": { + "objectNameField": "naam", + "objectSummaryField": "beschrijvingKort", + "objectDescriptionField": "beschrijvingLang", + "objectImageField": "logo", + "allowFiles": true, + "allowedTags": ["Documentatie", "Handleiding", "Technische specificatie"], + "autoPublish": true + } + }, + "compliancy": { + "uri": null, + "slug": "compliancy", + "title": "Compliancy", + "description": "Schema voor compliancy en standaard ondersteuning", + "version": "0.0.4", + "summary": "", + "icon": "CheckCircle", + "required": [], + "properties": { + "standaardversie": { + "description": "Standaardversie die door deze compliance wordt ondersteund", + "type": "object", + "order": 1, + "title": "Standaard Versie", + "visible": true, + "facetable": false, + "objectConfiguration": { + "handling": "related-object", + "queryParams": "gemmaType=standaardversie" + }, + "$ref": "#/components/schemas/element" + }, + "module": { + "description": "De module waarvan de compliance wordt geregistreerd", + "type": "object", + "order": 4, + "title": "Module", + "visible": true, + "facetable": false, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "compliancy" + }, + "bewijs": { + "description": "Bewijsstuk voor de compliance (bijvoorbeeld testrapport of certificaat)", + "type": "file", + "format": "base64", + "order": 5, + "title": "Bewijs", + "visible": true, + "facetable": false, + "fileConfiguration": { + "allowedMimeTypes": [ + "application/pdf", + "image/jpeg", + "image/png", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ], + "maxSize": 10485760 + } + } + }, + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-08-08T07:11:40+00:00", + "created": "2025-08-08T07:11:40+00:00", + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": null, + "deleted": null, + "configuration": { + "objectNameField": "module", + "objectSummaryField": "standaardversie", + "objectDescriptionField": "module", + "allowFiles": true, + "allowedTags": ["testraport"], + "autoPublish": true + } + }, + "moduleVersie": { + "uri": null, + "slug": "moduleVersie", + "title": "Module Versie", + "description": "Schema voor module versies", + "version": "0.0.4", + "summary": "", + "icon": "ViewModule", + "required": [], + "properties": { + "module": { + "description": "De module waarvan dit een versie is", + "type": "object", + "order": 1, + "title": "Module", + "facetable": false, + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/module", + "inversedBy": "moduleVersie" + }, + "versie": { + "description": "Versienummer in semantic versioning format (MAJOR.MINOR.PATCH)", + "type": "string", + "order": 2, + "title": "Versienummer", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "maxLength": 50, + "default": "1.0.0" + }, + "beschrijvingKort": { + "description": "Korte beschrijving van de module versie", + "type": "string", + "order": 3, + "title": "Wat is er nieuw of bijzonder in deze versie?", + "maxLength": 255 + }, + "beschrijvingLang": { + "description": "Uitgebreide beschrijving van de module versie", + "type": "string", + "order": 4, + "title": "Beschrijving", + "format": "markdown", + "maxLength": 5000 + }, + "status": { + "description": "De status van de module", + "type": "string", + "order": 13, + "title": "Status", + "enum": [ + "in ontwikkeling", + "in gebruik", + "einde ondersteuning", + "teruggetrokken" + ], + "default": "in gebruik" + }, + "datumInOntwikkeling": { + "description": "Startdatum van de ontwikkelingsfase", + "type": "string", + "format": "date", + "order": 14, + "title": "Datum In Ontwikkeling" + }, + "datumInGebruik": { + "description": "Startdatum van gebruik", + "type": "string", + "format": "date", + "order": 15, + "title": "Datum In Gebruik" + }, + "datumEindeOndersteuning": { + "description": "Startdatum einde ondersteuning", + "type": "string", + "format": "date", + "order": 16, + "title": "Datum Einde Ondersteuning" + }, + "datumTeruggetrokken": { + "description": "Datum waarop de module teruggetrokken is", + "type": "string", + "format": "date", + "order": 17, + "title": "Datum Teruggetrokken" + }, + "gebruiken": { + "description": "Het gebruik van deze module versie", + "type": "array", + "visible": true, + "order": 18, + "facetable": false, + "title": "Gebruik", + "items": { + "type": "object", + "objectConfiguration": { + "handling": "related-object" + }, + "$ref": "#/components/schemas/gebruik" } + } }, - "objects": [] + "archive": [], + "source": "", + "hardValidation": false, + "updated": "2025-08-08T07:11:40+00:00", + "created": "2025-08-08T07:11:40+00:00", + "maxDepth": 0, + "owner": "system", + "application": null, + "organisation": "cb2bca24-40bf-4568-a138-454c63ab761c", + "groups": null, + "authorization": null, + "deleted": null, + "configuration": { + "objectNameField": "versie", + "objectSummaryField": "beschrijvingKort", + "objectDescriptionField": "beschrijvingLang", + "autoPublish": true + } + } }, - "openapi": "3.0.0", - "info": { - "id": 14, - "title": "Software Catalog Register", - "description": "Merged register containing both AMEF and Voorzieningen schemas and configurations", - "version": "1.0.0" - } -} \ No newline at end of file + "objects": [] + }, + "openapi": "3.0.0", + "info": { + "id": 14, + "title": "Software Catalog Register", + "description": "Merged register containing both AMEF and Voorzieningen schemas and configurations", + "version": "1.0.2" + } +} diff --git a/src/components/GenericObjectTable.vue b/src/components/GenericObjectTable.vue index 43c1b49b..2fe64d59 100644 --- a/src/components/GenericObjectTable.vue +++ b/src/components/GenericObjectTable.vue @@ -36,6 +36,21 @@ import { objectStore, navigationStore } from '../store/store.js'
+ +
+ + + +
+ + +
+
+ + +
+
+
- + -
-
-

- - {{ getObjectTitle(item) }} -

- - - +