From 21f29c36a63efe69dbbae17f0eabb1a2c6213d08 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 27 Nov 2025 13:22:38 +0200 Subject: [PATCH] ApiSources --- app/plugins/Transmogrify/README.md | 9 +- .../Transmogrify/config/schema/tables.json | 224 ++++++++++-------- .../src/Command/TransmogrifyCommand.php | 104 ++++++++ .../src/Lib/Traits/CacheTrait.php | 150 ++++++++---- .../src/Lib/Traits/RowTransformationTrait.php | 70 ++++++ .../src/Lib/Traits/TypeMapperTrait.php | 49 ++++ 6 files changed, 463 insertions(+), 143 deletions(-) diff --git a/app/plugins/Transmogrify/README.md b/app/plugins/Transmogrify/README.md index 72ae342a..aea5742c 100644 --- a/app/plugins/Transmogrify/README.md +++ b/app/plugins/Transmogrify/README.md @@ -72,12 +72,19 @@ These options come directly from TransmogrifyCommand::buildOptionParser. - --groups-colon-replacement STRING - Optional: replace ":" with STRING in Standard group names during migration (opt‑in). Use with care; the name "CO" remains invalid and will not be auto‑renamed. +- --plugin-bootstrap + - Initialize plugin registry and activate plugins referenced in tables.json. + - Runs only the plugin bootstrap step and exits with success; it does not process or migrate any tables. + - Transmogrify scans tables.json for tables that declare a "plugin" and activates those plugins if present but suspended (eg, table "servers" with "plugin": "CoreServer" implies model "CoreServer.Servers"). Useful when preparing the deployment for a later migration that depends on plugin-provided models/schemas. ## Typical usage - Migrate everything using the default mapping - bin/cake transmogrify +- Run only plugin bootstrap (sync plugin registry and activate plugins referenced by tables.json), then exit (no migration) + - bin/cake transmogrify --plugin-bootstrap + - Preview environment information - bin/cake transmogrify --info - bin/cake transmogrify --info --info-json @@ -114,7 +121,7 @@ Hints: - You’ll see warnings for rows skipped due to unresolved foreign keys or missing type mappings. - Progress UI: - A single‑line progress bar updates in place. - - Warnings and errors appear under the bar as they happen. + - Warnings and errors appear over the bar as they happen. ## Per-table skip prompt diff --git a/app/plugins/Transmogrify/config/schema/tables.json b/app/plugins/Transmogrify/config/schema/tables.json index 24675ab6..7c2a128c 100644 --- a/app/plugins/Transmogrify/config/schema/tables.json +++ b/app/plugins/Transmogrify/config/schema/tables.json @@ -89,6 +89,7 @@ "source": "cm_api_users", "displayField": "username", "booleans": ["privileged"], + "cache": ["co_id"], "fieldMap": { "password": "api_key" } @@ -100,6 +101,7 @@ }, "servers": { "source": "cm_servers", + "plugin": "CoreServer", "displayField": "description", "addChangelog": true, "cache": ["co_id", "status"], @@ -110,6 +112,7 @@ }, "http_servers": { "source": "cm_http_servers", + "plugin": "CoreServer", "displayField": "serverurl", "addChangelog": true, "booleans": [ @@ -125,6 +128,7 @@ }, "oauth2_servers": { "source": "cm_oauth2_servers", + "plugin": "CoreServer", "displayField": "serverurl", "addChangelog": true, "cache": ["server_id"], @@ -136,6 +140,7 @@ }, "sql_servers": { "source": "cm_sql_servers", + "plugin": "CoreServer", "displayField": "hostname", "cache": ["server_id"], "addChangelog": true, @@ -145,6 +150,7 @@ }, "match_servers": { "source": "cm_match_servers", + "plugin": "CoreServer", "displayField": "username", "addChangelog": true, "booleans": [ @@ -162,6 +168,7 @@ "match_server_attributes": { "source": "cm_match_server_attributes", "displayField": "attribute", + "plugin": "CoreServer", "addChangelog": true, "booleans": [ "required" @@ -183,6 +190,129 @@ "co_message_template_id": "message_template_id" } }, + "pipelines": { + "source": "cm_co_pipelines", + "displayField": "description", + "cache": ["co_id"], + "fieldMap": { + "name": "description", + "sync_on_add": null, + "sync_on_update": null, + "sync_on_delete": null, + "sync_coperson_status": null, + "sync_coperson_attributes": null, + "create_role": null, + "match_type": null, + "sync_affiliation": null, + "sync_identifier_type": null, + "sync_affiliation_type_id": "&mapAffiliationType", + "sync_identifier_type_id": "&mapIdentifierType", + "match_identifier_type_id": "&mapIdentifierType", + "match_email_address_type_id": "&mapEmailType", + "establish_clusters": null, + "co_enrollment_flow_id": null, + "co_pipeline_id": "pipeline_id" + } + }, + "external_identity_sources": { + "source": "cm_org_identity_sources", + "displayField": "description", + "booleans": ["hash_source_record"], + "cache": ["co_id"], + "fieldMap": { + "plugin": "&mapExternalIdentitySourcePlugin", + "co_pipeline_id": "pipeline_id", + "sync_mode": null, + "sync_query_mismatch_mode": null, + "sync_query_skip_known": null, + "sync_on_user_login": null, + "eppn_identifier_type": null, + "eppn_suffix": null, + "org_identity_source_id": "external_identity_source_id" + } + }, + "orcid_sources": { + "source": "cm_orcid_sources", + "displayField": "id", + "plugin": "OrcidSource", + "booleans": [ + "scope_inherit" + ], + "cache": ["external_identity_source_id", "server_id"], + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "default_affiliation_type_id": "&mapToDefaultAffiliationTypeId", + "address_type_id": "&mapToDefaultAddressTypeId", + "email_address_type_id": "&mapToDefaultEmailAddressTypeId", + "name_type_id": "&mapToDefaultNameTypeId", + "telephone_number_type_id": "&mapToDefaultTelephoneNumberTypeId" + }, + "addChangelog": true + }, + "orcid_tokens": { + "source": "cm_orcid_tokens", + "displayField": "id", + "plugin": "OrcidSource", + "cache": ["orcid_identifier", "orcid_source_id"], + "addChangelog": true + }, + "env_sources": { + "source": "cm_env_sources", + "plugin": "EnvSource", + "displayField": "id", + "addChangelog": true, + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "duplicate_mode": null, + "redirect_on_duplicate": "redirect_on_duplicate", + "sp_type": "sp_mode", + "default_affiliation_type_id": "&mapAffiliationType", + "default_affiliation": null, + "address_type_id": "&mapToDefaultAddressTypeId", + "email_address_type_id": "&mapToDefaultEmailAddressTypeId", + "name_type_id": "&mapToDefaultNameTypeId", + "telephone_number_type_id": "&mapToDefaultTelephoneNumberTypeId", + "env_o": "env_organization", + "env_ou": "env_department", + "env_identifier_eppn_login": null, + "env_identifier_eptid_login": null, + "env_identifier_epuid_login": null, + "env_identifier_oidcsub_login": null, + "env_identifier_orcid": null, + "env_identifier_orcid_login": null, + "env_identifier_samlpairwiseid_login": null, + "env_identifier_samlsubjectid_login": null, + "env_identifier_sorid": "env_identifier_sourcekey", + "env_identifier_sorid_login": null, + "env_identifier_network_login": null + } + }, + "api_sources": { + "source": "cm_api_sources", + "plugin": "ApiConnector", + "displayField": "id", + "postRow": "createApiForApiSource", + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "api_user_id": null, + "poll_mode": null, + "kafka_server_id": null + }, + "addChangelog": true + }, + "api_source_endpoints": { + "source": "cm_api_sources", + "plugin": "ApiConnector", + "displayField": "id", + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "api_id": "&mapApiIdFromCache", + "api_user_id": null, + "poll_mode": null, + "kafka_server_id": null + }, + "addChangelog": true + }, "__NOTES__": "DATA MIGRATIONS", "authentication_events": { "source": "cm_authentication_events", @@ -551,47 +681,6 @@ }, "addChangelog": true }, - "pipelines": { - "source": "cm_co_pipelines", - "displayField": "description", - "cache": ["co_id"], - "fieldMap": { - "name": "description", - "sync_on_add": null, - "sync_on_update": null, - "sync_on_delete": null, - "sync_coperson_status": null, - "sync_coperson_attributes": null, - "create_role": null, - "match_type": null, - "sync_affiliation": null, - "sync_identifier_type": null, - "sync_affiliation_type_id": "&mapAffiliationType", - "sync_identifier_type_id": "&mapIdentifierType", - "match_identifier_type_id": "&mapIdentifierType", - "match_email_address_type_id": "&mapEmailType", - "establish_clusters": null, - "co_enrollment_flow_id": null, - "co_pipeline_id": "pipeline_id" - } - }, - "external_identity_sources": { - "source": "cm_org_identity_sources", - "displayField": "description", - "booleans": ["hash_source_record"], - "cache": ["co_id"], - "fieldMap": { - "plugin": "&mapExternalIdentitySourcePlugin", - "co_pipeline_id": "pipeline_id", - "sync_mode": null, - "sync_query_mismatch_mode": null, - "sync_query_skip_known": null, - "sync_on_user_login": null, - "eppn_identifier_type": null, - "eppn_suffix": null, - "org_identity_source_id": "external_identity_source_id" - } - }, "ext_identity_source_records": { "source": "cm_org_identity_source_records", "displayField": "id", @@ -606,58 +695,5 @@ "reference_identifier": "reference_identifier", "org_identity_source_record_id": "ext_identity_source_record_id" } - }, - "orcid_sources": { - "source": "cm_orcid_sources", - "displayField": "id", - "booleans": [ - "scope_inherit" - ], - "cache": ["external_identity_source_id", "server_id"], - "fieldMap": { - "org_identity_source_id": "external_identity_source_id", - "default_affiliation_type_id": "&mapToDefaultAffiliationTypeId", - "address_type_id": "&mapToDefaultAddressTypeId", - "email_address_type_id": "&mapToDefaultEmailAddressTypeId", - "name_type_id": "&mapToDefaultNameTypeId", - "telephone_number_type_id": "&mapToDefaultTelephoneNumberTypeId" - }, - "addChangelog": true - }, - "orcid_tokens": { - "source": "cm_orcid_tokens", - "displayField": "id", - "cache": ["orcid_identifier", "orcid_source_id"], - "addChangelog": true - }, - "env_sources": { - "source": "cm_env_sources", - "displayField": "id", - "addChangelog": true, - "fieldMap": { - "org_identity_source_id": "external_identity_source_id", - "duplicate_mode": null, - "redirect_on_duplicate": "redirect_on_duplicate", - "sp_type": "sp_mode", - "default_affiliation_type_id": "&mapAffiliationType", - "default_affiliation": null, - "address_type_id": "&mapToDefaultAddressTypeId", - "email_address_type_id": "&mapToDefaultEmailAddressTypeId", - "name_type_id": "&mapToDefaultNameTypeId", - "telephone_number_type_id": "&mapToDefaultTelephoneNumberTypeId", - "env_o": "env_organization", - "env_ou": "env_department", - "env_identifier_eppn_login": null, - "env_identifier_eptid_login": null, - "env_identifier_epuid_login": null, - "env_identifier_oidcsub_login": null, - "env_identifier_orcid": null, - "env_identifier_orcid_login": null, - "env_identifier_samlpairwiseid_login": null, - "env_identifier_samlsubjectid_login": null, - "env_identifier_sorid": "env_identifier_sourcekey", - "env_identifier_sorid_login": null, - "env_identifier_network_login": null - } } } diff --git a/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php b/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php index acc1cfc4..8e7aec9b 100644 --- a/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php +++ b/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php @@ -29,6 +29,7 @@ namespace Transmogrify\Command; +use App\Lib\Enum\SuspendableStatusEnum; use App\Lib\Traits\LabeledLogTrait; use App\Lib\Util\DBALConnection; use App\Lib\Util\StringUtilities; @@ -192,6 +193,10 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'help' => 'Use "-" as the replacement for ":" in Standard group names (shorthand when passing a lone "-" is problematic)', 'boolean' => true ]); + $parser->addOption('plugin-bootstrap', [ + 'help' => 'Initialize plugin registry and activate non-required plugins (eg, passwords, ssh keys) before transmogrification', + 'boolean' => true + ]); $parser->setEpilog(__d('command', 'tm.epilog')); @@ -271,6 +276,17 @@ public function execute(Arguments $args, ConsoleIo $io): int $pendingSelected = array_fill_keys($selected, true); } + // Plugin bootstrap is now optional and controlled by the CLI flag + if ($this->args->getOption('plugin-bootstrap')) { + try { + $this->pluginBootstrap(); + return BaseCommand::CODE_SUCCESS; + } catch (\Throwable $e) { + $this->cmdPrinter?->error('Plugin bootstrap failed: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } + } + foreach(array_keys($this->tables) as $t) { // Check per-table skip configuration and optionally prompt user $canSkipCfg = $this->tables[$t]['canSkip'] ?? null; @@ -661,6 +677,94 @@ private function maybeValidateSelectedTables(array $selected, ConsoleIo $io): ?i return null; } + /** + * Bootstrap plugin state for transmogrification when requested via --plugin-bootstrap: + * - Ensure the Plugins table is in sync with plugins on disk. + * - Activate any plugins that are referenced in tables.json (via the "plugin" key), + * if they are present but currently suspended. + * + * For a table entry like: + * "servers": { "plugin": "CoreServer", ... } + * the corresponding model will be "CoreServer.Servers". + * + * @return void + */ + protected function pluginBootstrap(): void + { + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + $this->cmdPrinter?->info(PHP_EOL . 'Initializing plugin registry for transmogrification...'); + + // 1. Make sure the registry reflects what is actually available on disk. + $Plugins->syncPluginRegistry(); + + // 2. Collect plugins referenced by tables.json via the "plugin" key. + // We also compute the full model path "Plugin.TableClass" for logging. + $referencedPlugins = []; // [pluginName => true] + $pluginModels = []; // [pluginName => [fullModelName1, fullModelName2, ...]] + + foreach ($this->tables as $tableName => $cfg) { + if (empty($cfg['plugin']) || !is_string($cfg['plugin'])) { + continue; + } + + $pluginName = $cfg['plugin']; + $referencedPlugins[$pluginName] = true; + + $tableClass = Inflector::classify($tableName); // eg "servers" -> "Servers" + $fullModel = $pluginName . '.' . $tableClass; + + if (!isset($pluginModels[$pluginName])) { + $pluginModels[$pluginName] = []; + } + $pluginModels[$pluginName][] = $fullModel; + } + + if (empty($referencedPlugins)) { + $this->cmdPrinter?->info('No plugins referenced in tables.json; plugin bootstrap skipped.' . PHP_EOL); + return; + } + + $this->cmdPrinter?->verbose('Plugins referenced in tables.json:'); + foreach ($pluginModels as $pluginName => $models) { + $this->cmdPrinter?->verbose(sprintf( + ' - %s (%s)', + $pluginName, + implode(', ', array_unique($models)) + )); + } + + // 3. Ensure each referenced plugin exists and is active. + foreach (array_keys($referencedPlugins) as $pluginName) { + $plugin = $Plugins->find() + ->where(['plugin' => $pluginName]) + ->first(); + + if ($plugin === null) { + $this->cmdPrinter?->warning(sprintf( + 'Plugin "%s" is referenced in tables.json but not registered in Plugins table.', + $pluginName + )); + continue; + } + + if ($plugin->status === SuspendableStatusEnum::Active) { + continue; + } + + $this->cmdPrinter?->info(sprintf( + 'Activating plugin "%s" referenced in tables.json.', + $pluginName + )); + + // PluginsTable::activate() will also apply the plugin schema if defined. + $Plugins->activate((int)$plugin->id); + } + + $this->cmdPrinter?->info('Plugin initialization complete.' . PHP_EOL); + } + + /** * Check if a table exists in the source database * diff --git a/app/plugins/Transmogrify/src/Lib/Traits/CacheTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/CacheTrait.php index c5571db5..34bec331 100644 --- a/app/plugins/Transmogrify/src/Lib/Traits/CacheTrait.php +++ b/app/plugins/Transmogrify/src/Lib/Traits/CacheTrait.php @@ -38,15 +38,94 @@ trait CacheTrait */ protected array $cache = []; + /** + * Cache a composite key for a list of fields, pointing to the row ID. + * + * This is used for entries like ["co_id", "attribute", "value"] where we + * want a lookup of the form: + * cache[table]["co_id+attribute+value+"]["2+Identifier.type+eppn+"] = row_id + * + * @param string $table Logical table name + * @param array $row Current row data + * @param array $fields List of field names that form the composite key + * @return void + */ + protected function cacheCompositeKey(string $table, array $row, array $fields): void + { + // This is a list of fields, create a composite key that point to the row ID + $label = ""; + $key = ""; + + foreach ($fields as $subfield) { + // eg: co_id+attribute+value+ + $label .= $subfield . "+"; + + // eg: 2+Identifier.type+eppn+ + $value = $row[$subfield] ?? ''; + $key .= $value . "+"; + } + + $this->cache[$table][$label] ??= []; + if (isset($row['id'])) { + $this->cache[$table][$label][$key] = $row['id']; + } + } + + /** + * Cache a simple field value under the row ID bucket (with merge semantics). + * + * If the id is missing, this is a no-op. + * + * @param string $table Logical table name + * @param array $row Current row data + * @param string $field Field name to cache + * @return void + */ + protected function cacheFieldById(string $table, array $row, string $field): void + { + if (!array_key_exists($field, $row)) { + return; + } + + $id = $row['id'] ?? null; + if ($id === null) { + return; + } + + // Ensure the id bucket is initialized + $this->cache[$table]['id'] ??= []; + $this->cache[$table]['id'][$id] ??= []; + + // If a value exists: + // - If both existing and incoming are arrays, merge recursively. + // - Otherwise, set only when the key is not present to avoid clobbering + // previously injected/nested data (like `enrollment_flow_steps`). + if (array_key_exists($field, $this->cache[$table]['id'][$id])) { + $existing = $this->cache[$table]['id'][$id][$field]; + $incoming = $row[$field]; + + if (is_array($existing) && is_array($incoming)) { + $this->cache[$table]['id'][$id][$field] = array_replace_recursive($existing, $incoming); + } else { + // Do not overwrite an existing non-array value or structure + // If you do want to force an overwrite for specific fields, handle by name here. + // e.g., if (in_array($field, ['co_id', ...], true)) { $this->cache[...] = $incoming; } + } + } else { + $this->cache[$table]['id'][$id][$field] = $row[$field]; + } + } + /** * Cache results as configured for the specified table. * * @since COmanage Registry v5.0.0 - * @param string $table Table to cache - * @param array $row Row of table data + * @param string $table Table to cache + * @param array $row Row of table data + * @param array $orinRow Original Row of table data */ - protected function cacheResults(string $table, array $row): void + protected function cacheResults(string $table, array $row, array $orinRow): void { if (empty($this->tables[$table]['cache'])) { return; @@ -55,52 +134,10 @@ protected function cacheResults(string $table, array $row): void // Cache the requested fields. For now, at least, we key on row ID only. foreach ($this->tables[$table]['cache'] as $field) { if (is_array($field)) { - // This is a list of fields, create a composite key that point to the row ID - - $label = ""; - $key = ""; - - foreach ($field as $subfield) { - // eg: co_id+attribute+value+ - $label .= $subfield . "+"; - - // eg: 2+Identifier.type+eppn+ - $key .= $row[$subfield] . "+"; - } - - $this->cache[$table][$label] ??= []; - $this->cache[$table][$label][$key] = $row['id']; + $this->cacheCompositeKey($table, $row, $field); } else { - // If the row has the field then map id to the requested field safely. - if (array_key_exists($field, $row)) { - $id = $row['id'] ?? null; - if ($id === null) { - continue; - } - - // Ensure the id bucket is initialized - $this->cache[$table]['id'] ??= []; - $this->cache[$table]['id'][$id] ??= []; - - // If a value exists: - // - If both existing and incoming are arrays, merge recursively. - // - Otherwise, set only when the key is not present to avoid clobbering - // previously injected/nested data (like `enrollment_flow_steps`). - if (array_key_exists($field, $this->cache[$table]['id'][$id])) { - $existing = $this->cache[$table]['id'][$id][$field]; - $incoming = $row[$field]; - - if (is_array($existing) && is_array($incoming)) { - $this->cache[$table]['id'][$id][$field] = array_replace_recursive($existing, $incoming); - } else { - // Do not overwrite an existing non-array value or structure - // If you do want to force an overwrite for specific fields, handle by name here. - // e.g., if (in_array($field, ['co_id', ...], true)) { $this->cache[...] = $incoming; } - } - } else { - $this->cache[$table]['id'][$id][$field] = $row[$field]; - } - } + $effectiveRow = array_key_exists($field, $row) ? $row : $orinRow; + $this->cacheFieldById($table, $effectiveRow, $field); } } } @@ -135,6 +172,8 @@ protected function findCoId(array $row): int isset($row['match_server_id']) => $this->getCoIdFromMatchServer((int)$row['match_server_id']), + isset($row['api_user_id']) => $this->getCoIdFromApiUserId((int)$row['api_user_id']), + // Legacy/preRow: org_identity_id follows the same External Identity path isset($row['org_identity_id']) => $this->getCoIdFromExternalIdentityId((int)$row['org_identity_id']), @@ -186,6 +225,21 @@ private function getPersonIdFromExternalIdentity(int $externalIdentityId): ?int return $personId !== null ? (int)$personId : null; } + /** + * Resolve a CO ID from an API User ID via cache. + * + * @param int $apiUserId API User ID to resolve + * @return int|null CO ID if found, null otherwise + * @since COmanage Registry v5.2.0 + */ + private function getCoIdFromApiUserId(int $apiUserId): ?int + { + if (isset($this->cache['api_users']['id'][$apiUserId]['co_id'])) { + return (int)$this->cache['api_users']['id'][$apiUserId]['co_id']; + } + return null; + } + /** * Resolve a CO ID from a Group ID via cache. * diff --git a/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php index e1c60a46..25da7e1f 100644 --- a/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php +++ b/app/plugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php @@ -317,6 +317,75 @@ protected function migrateExtendedAttributesToAdHocAttributes(): void } } + /** + * Create a core API row for each ApiSource plugin row. + * + * Intended as a postRow hook for the "api_sources" table. + * + * @param array $origRow Original cm_api_sources row + * @param array $row Inserted api_sources row (already mapped) + * @return void + * @throws Exception + */ + protected function createApiForApiSource(array $origRow, array $row): void + { + // We need the target ApiSource ID on the plugin table + if (empty($origRow['id'])) { + return; + } + $apiSourceId = (int)$origRow['id']; + + // api_user_id is optional; if missing we can't derive co_id via API user + $apiUserId = $origRow['api_user_id'] ?? null; + if ($apiUserId === null) { + return; + } + + // Derive CO ID from api_user_id; may return null if user not mapped + $coId = $this->mapCoIdFromApiUserId(['api_user_id' => $apiUserId]); + if ($coId === null) { + return; + } + + $apisTable = $this->outconn->qualifyTableName('apis'); + + // Build API row + $apiRow = [ + 'co_id' => $coId, + 'description' => '(Transmogrify) API Source Plugin', + 'plugin' => 'ApiConnector.ApiSourceEndpoints', + 'status' => 'A', + 'api_user_id' => $apiUserId, + 'created' => $this->mapNow([]), + 'modified' => $this->mapNow([]), + ]; + + // Apply changelog defaults and boolean normalization + $this->populateChangelogDefaults('apis', $apiRow, true); + $this->normalizeBooleanFieldsForDb('apis', $apiRow); + + $this->outconn->beginTransaction(); + try { + // Insert into apis and cache mapping for later lookup + $this->outconn->insert($apisTable, $apiRow); + + if (!method_exists($this->outconn, 'lastInsertId')) { + throw new \RuntimeException('Could not retrieve API ID'); + } + $apiId = (int)$this->outconn->lastInsertId(); + + $this->outconn->commit(); + // Cache relation so mapApiIdFromCache can resolve api_id by source ID + $externalSourceId = $row['external_identity_source_id'] ?? $row['org_identity_source_id'] ?? null; + if ($externalSourceId !== null) { + $this->cache['apis']['id'][$apiId]['org_identity_source_id'] = $externalSourceId; + } + } catch (\Throwable $e) { + $this->outconn->rollBack(); + throw new \RuntimeException("Failed to create API record for API Source($apiSourceId): " . $e->getMessage()); + } + } + /** * Creates a new enrollment flow step for the given enrollment flow * @@ -516,6 +585,7 @@ private function performFunctionMapping(array &$row, string $oldname, string $fu $nullableFuncs = [ 'mapAffiliationType', 'mapHistoricPetitionViewerId', + 'mapCoIdFromApiUserId', ]; // The pipelines table allows the identifier and email types to be null diff --git a/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php index d398cffd..78ef9a96 100644 --- a/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php +++ b/app/plugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php @@ -31,6 +31,7 @@ use App\Lib\Enum\PetitionStatusEnum; use Cake\ORM\TableRegistry; +use Cake\Utility\Hash; use Cake\Utility\Inflector; use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; @@ -192,6 +193,54 @@ protected function mapAffiliationType(array $row, ?int $coId = null): ?int } + /** + * Map API User ID to corresponding CO ID + * + * @param array $row Row data containing api_user_id + * @return int|null Mapped CO ID or null if not found + * @since COmanage Registry v5.2.0 + */ + protected function mapCoIdFromApiUserId(array $row): ?int { + return $this->getCoIdFromApiUserId($row['api_user_id']); + } + + + /** + * Maps organization identity source ID to API ID using cached API data. + * + * Looks up API ID from cached API data based on the organization identity source ID. + * Returns null if cache is empty or mapping not found. + * + * @param array $row Row data containing org_identity_source_id + * @return int|null Mapped API ID or null if not found + * @since COmanage Registry v5.2.0 + */ + protected function mapApiIdFromCache(array $row): ?int { + $apis = $this->cache['apis'] ?? null; + + if ($apis === null) { + return null; + } + + $orgIdentitySourceId = $row['org_identity_source_id'] ?? $row["external_identity_source_id"] ?? null; + + if ($orgIdentitySourceId === null) { + null; + } + + // [id..org_identity_source_id] => + $flattenedApis = Hash::flatten($apis); + $key = array_search($orgIdentitySourceId, $flattenedApis, true); + + if ($key === false) { + return null; + } + + $parts = explode('.', $key); + return (int)$parts[1] ?? null; + } + + /** * Map v4 External Identity Source plugin name to v5 plugin model path. *