diff --git a/app/plugins/Transmogrify/config/schema/tables.json b/app/plugins/Transmogrify/config/schema/tables.json index 3da5ec51e..9cdb15f19 100644 --- a/app/plugins/Transmogrify/config/schema/tables.json +++ b/app/plugins/Transmogrify/config/schema/tables.json @@ -368,6 +368,27 @@ }, "postTable": "migrateExtendedAttributesToAdHocAttributes" }, + "notifications": { + "source": "cm_co_notifications", + "displayField": "id", + "addChangelog": true, + "fieldMap": { + "subject_co_person_id": "subject_person_id", + "subject_co_group_id": "subject_group_id", + "actor_co_person_id": "actor_person_id", + "recipient_co_person_id": "recipient_person_id", + "recipient_co_group_id": "recipient_group_id", + "resolver_co_person_id": "resolver_person_id", + "action": "&mapNotificationAction", + "source_url": "source", + "source_controller": null, + "source_action": null, + "source_id": null, + "source_arg0": null, + "source_val0": null, + "email_body": "email_body_text" + } + }, "history_records": { "source": "cm_history_records", "displayField": "id", diff --git a/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php b/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php index 73a325a1e..b218d365b 100644 --- a/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php +++ b/app/plugins/Transmogrify/src/Command/TransmogrifyCommand.php @@ -44,6 +44,7 @@ use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException; use Transmogrify\Lib\Enum\TransmogrifyEnum; +use Transmogrify\Lib\Traits\ActionCodeMapperTrait; use Transmogrify\Lib\Traits\CacheTrait; use Transmogrify\Lib\Traits\HookRunnersTrait; use Transmogrify\Lib\Traits\ManageDefaultsTrait; @@ -51,13 +52,14 @@ use Transmogrify\Lib\Traits\TypeMapperTrait; use Transmogrify\Lib\Util\CommandLinePrinter; use Transmogrify\Lib\Util\DbInfoPrinter; -use Transmogrify\Lib\Util\RawSqlQueries; -use Transmogrify\Lib\Util\OrgIdentitiesHealth; use Transmogrify\Lib\Util\GroupsHealth; +use Transmogrify\Lib\Util\OrgIdentitiesHealth; +use Transmogrify\Lib\Util\RawSqlQueries; class TransmogrifyCommand extends BaseCommand { use CacheTrait; use TypeMapperTrait; + use ActionCodeMapperTrait; use RowTransformationTrait; use ManageDefaultsTrait; use HookRunnersTrait; @@ -288,7 +290,7 @@ public function execute(Arguments $args, ConsoleIo $io): int if(!empty($this->tables[$t]['source'])) { $src = $this->tables[$t]['source']; if (!$this->tableExists($src)) { - $this->io->warning("Source table '$src' does not exist in source database, skipping table '$t'"); + $this->cmdPrinter->warning("Source table '$src' does not exist in source database, skipping table '$t'"); continue; } } diff --git a/app/plugins/Transmogrify/src/Lib/Traits/ActionCodeMapperTrait.php b/app/plugins/Transmogrify/src/Lib/Traits/ActionCodeMapperTrait.php new file mode 100644 index 000000000..2cad53c17 --- /dev/null +++ b/app/plugins/Transmogrify/src/Lib/Traits/ActionCodeMapperTrait.php @@ -0,0 +1,240 @@ + + */ + protected const ACTION_CODE_DIRECT_MAP = [ + // Authenticators + 'EAUT' => 'EAUT', // AuthenticatorEdited + + // Comments + 'CMNT' => 'CMNT', // CommentAdded + + // Email + 'EMLV' => 'EMLV', // EmailAddressVerified -> EmailVerified + 'EMLS' => 'EMLS', // EmailAddressVerifyReqSent -> EmailVerifyCodeSent + + // External Identity / Login env + 'EOIE' => 'EOIE', // OrgIdEditedLoginEnv -> ExternalIdentityLoginUpdate + + // Groups + 'ACGR' => 'ACGR', // CoGroupAdded -> GroupAdded + 'DCGR' => 'DCGR', // CoGroupDeleted -> GroupDeleted + 'ECGR' => 'ECGR', // CoGroupEdited -> GroupEdited + + // Group Members + 'ACGM' => 'ACGM', // CoGroupMemberAdded -> GroupMemberAdded + 'DCGM' => 'DCGM', // CoGroupMemberDeleted -> GroupMemberDeleted + 'ECGM' => 'ECGM', // CoGroupMemberEdited -> GroupMemberEdited + + // Identifiers / Matching + 'AIDA' => 'AIDA', // IdentifierAutoAssigned + 'UMAT' => 'UMAT', // MatchAttributesUpdated + + // Names + 'PNAM' => 'PNAM', // NamePrimary + + // Notifications + 'NOTA' => 'NOTA', // NotificationAcknowledged + 'NOTX' => 'NOTX', // NotificationCanceled + 'NOTD' => 'NOTD', // NotificationDelivered + 'NOTR' => 'NOTR', // NotificationResolved + + // Person pipeline + 'ACPP' => 'ACPP', // CoPersonAddedPetition -> PersonAddedPetition + 'ACPL' => 'ACPL', // CoPersonAddedPipeline -> PersonAddedPipeline + 'MCPL' => 'MCPL', // CoPersonMatchedPipeline -> PersonMatchedPipeline + + // Person status + 'RCPS' => 'RCPS', // CoPersonStatusRecalculated -> PersonStatusRecalculated + + // Petitions + 'CPPC' => 'CPPC', // CoPetitionCreated -> PetitionCreated + 'CPUP' => 'CPUP', // CoPetitionUpdated -> PetitionUpdated + + // Reference ID + 'OIDR' => 'OIDR', // ReferenceIdentifierObtained + ]; + + /** + * v4 codes that map to v5 ActionEnum with a changed code. + * + * @var array + */ + protected const ACTION_CODE_RENAMED_MAP = [ + // CoPersonRoleRelinked (LCRM) -> PersonRoleRelinked (LCPR) + 'LCRM' => 'LCPR', + ]; + + /** + * v4 ActionEnum codes that moved to PetitionActionEnum in v5. + * + * Keys are v4 codes, values are v5 PetitionActionEnum codes. + * + * @var array + */ + protected const ACTION_CODE_PETITION_MAP = [ + // Invitations moved into PetitionActionEnum + 'INVC' => 'IC', // InvitationConfirmed -> Accepted + 'INVD' => 'PX', // InvitationDeclined -> Declined + 'INVV' => 'IV', // InvitationViewed -> InvitationViewed + // 'INVE' => null, // InvitationExpired (no explicit equivalent) + // 'INVS' => null, // InvitationSent (no explicit equivalent) + ]; + + /** + * Optional/opinionated mappings to collapse specific attribute events (names) + * into v5’s generic MVEA* events. Disabled by default for correctness. + * + * @var array + */ + protected const ACTION_CODE_OPTIONAL_OPINIONATED_MAP = [ + // Names -> generic Multi-Valued Extended Attribute events + 'ANAM' => 'AMVE', // NameAdded -> MVEAAdded + 'ENAM' => 'EMVE', // NameEdited -> MVEAEdited + 'DNAM' => 'DMVE', // NameDeleted -> MVEADeleted + ]; + + /** + * Map a v4 ActionEnum right-hand code to v5. + * + * Returns: + * - enum: 'ActionEnum' | 'PetitionActionEnum' | null + * - code: string|null + * + * When enum is null, there is no v5 equivalent; callers can log/skip. + * + * @param string $v4Code + * @param bool $enableOpinionated Enable optional generalized mappings (default=false) + * @return array{enum: string|null, code: string|null} + */ + protected function mapActionCode(string $v4Code, bool $enableOpinionated = false): array + { + $key = strtoupper(trim($v4Code)); + + if (isset(self::ACTION_CODE_DIRECT_MAP[$key])) { + return ['enum' => 'ActionEnum', 'code' => self::ACTION_CODE_DIRECT_MAP[$key]]; + } + + if (isset(self::ACTION_CODE_RENAMED_MAP[$key])) { + return ['enum' => 'ActionEnum', 'code' => self::ACTION_CODE_RENAMED_MAP[$key]]; + } + + if (isset(self::ACTION_CODE_PETITION_MAP[$key])) { + return ['enum' => 'PetitionActionEnum', 'code' => self::ACTION_CODE_PETITION_MAP[$key]]; + } + + if ($enableOpinionated && isset(self::ACTION_CODE_OPTIONAL_OPINIONATED_MAP[$key])) { + return ['enum' => 'ActionEnum', 'code' => self::ACTION_CODE_OPTIONAL_OPINIONATED_MAP[$key]]; + } + + return ['enum' => null, 'code' => null]; + } + + /** + * Convenience: map from a row array. Tries 'action' first, then 'action_code'. + * + * @param array $row + * @param bool $enableOpinionated + * @return array{enum: string|null, code: string|null} + */ + protected function mapActionFromRow(array $row, bool $enableOpinionated = false): array + { + $code = null; + + if (isset($row['action']) && is_string($row['action'])) { + $code = $row['action']; + } + + if ($code === null) { + return ['enum' => null, 'code' => null]; + } + + return $this->mapActionCode($code, $enableOpinionated); + } + + /** + * Map a cm_co_notifications row’s action code to a v5 ActionEnum code. + * For notifications we only accept ActionEnum; PetitionActionEnum mappings return null. + */ + protected function mapNotificationAction(array $row): ?string + { + $m = $this->mapActionFromRow($row); + + if ($m['enum'] === 'ActionEnum') { + return $m['code']; + } + + // No ActionEnum equivalent (eg, invitation events that moved to PetitionActionEnum) + $rawCode = null; + if (isset($row['action']) && is_string($row['action'])) { + $rawCode = $row['action']; + } + + if ($rawCode !== null && isset($this->cmdPrinter)) { + $unmapped = $this->listUnmappedActionCodes([$rawCode], false); + if (!empty($unmapped)) { + $this->cmdPrinter->warning(sprintf('Skipping notification with unmapped action code: %s', $unmapped[0])); + } + } + + return null; + } + + /** + * Report which v4 action codes won’t map under current settings. + * + * @param string[] $seenV4Codes + * @param bool $enableOpinionated + * @return string[] + */ + protected function listUnmappedActionCodes(array $seenV4Codes, bool $enableOpinionated = false): array + { + $unmapped = []; + + foreach ($seenV4Codes as $c) { + $m = $this->mapActionCode((string)$c, $enableOpinionated); + if ($m['enum'] === null) { + $unmapped[] = strtoupper(trim((string)$c)); + } + } + + return array_values(array_unique($unmapped)); + } +}