*/ public function app(): array { return array( 'order' => 40, 'category' => 'configuration', 'core' => false, 'deps' => array( ), 'default' => true, 'label_key' => 'app_integrations', 'desc_key' => 'app_integrations_desc', 'icon' => 'extension', 'cron' => array( 'plugixa_crm_run_integration_jobs' ), ); } /** * @return array{label: string, desc: string} */ public function labels(): array { return array( 'label' => __( 'Integrations', 'plugixa-crm' ), 'desc' => __( 'Connectors to other services, and the queue that runs them.', 'plugixa-crm' ), ); } public function migrate(): void { Schema::install(); $this->retire_automation(); } /** * Clear up after the workflow engine, which was withdrawn in 3.36.0. * * IT HAS TO LIVE HERE BECAUSE THE MODULE THAT OWNED THIS DEBT NO LONGER EXISTS. A module normally * cleans up after itself in its own `migrate()`; `Modules\Automation` was deleted, so its two * tables, its cron and its rows on THIS module's queue would otherwise sit on every upgraded site * for ever. `Core\Database\LegacyCleanup` is not the place either — its list is the six ERP packs' * bare names and it is one-shot behind an option of its own. * * THE ORDER IS LOAD-BEARING, top to bottom: * * 1. The cron goes first, so nothing can fire half-way through. Nothing re-schedules it once the * module is gone, so this cannot be undone by a stale request arriving mid-upgrade. * 2. Then the queued runs, because a leftover `automation.run` row would settle `failed` with * *no handler registered* and fill the brand-new Jobs section of the Configure screen with * noise about a feature that is not in this build. * 3. Then the switch is seeded from the two legacy flags, BEFORE they are deleted. * 4. Then those flags go — which also retires the two options that leaked past uninstall on every * site, since neither was under {@see Credentials::PREFIX}. * 5. The tables go last, after the jobs that referenced them. * * NO GUARD OPTION. Every step is idempotent — clear a cron that is gone, delete rows that are * gone, seed only when absent, drop tables that are gone — so a guard would be one more option to * name, reserve and sweep for no benefit at all. `Modules\Cases\Module::backfill_numbers()` is * unguarded for exactly the same reason. */ private function retire_automation(): void { wp_clear_scheduled_hook( 'plugixa_crm_prune_automation_runs' ); global $wpdb; $jobs = Db::table( 'integration_jobs' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time migration over a custom table. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE integration = %s', $jobs, 'automation' ) ); /* * SEEDED ONLY WHEN THE SWITCH HAS NEVER BEEN WRITTEN. `State` defaults to off, so without this * a live shop would silently stop syncing the moment it upgraded — the connector was "on" by * way of an option this release stops reading. Writing it only when absent means a re-run * cannot un-toggle a decision somebody made after the upgrade. */ if ( false === get_option( State::OPTION, false ) ) { $seeded = array(); if ( '1' === (string) get_option( 'plugixa_crm_woo_connected', '' ) ) { $seeded['woocommerce'] = true; } if ( '1' === (string) get_option( 'plugixa_crm_forms_connected', '' ) ) { $seeded['forms'] = true; } update_option( State::OPTION, $seeded, true ); } delete_option( 'plugixa_crm_woo_connected' ); delete_option( 'plugixa_crm_forms_connected' ); foreach ( array( 'automation_workflows', 'automation_runs' ) as $gone ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- retiring a withdrawn module's tables. $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', Db::table( $gone ) ) ); } } /** * Nothing to grant. The screen is `manage_options`, which every administrator already has, and a * capability nobody checks for is worse than none. * * @return array */ public function capabilities(): array { return array(); } public function registerServices( Container $container ): void { unset( $container ); } public function registerRoutes( Container $container ): void { $container->get( IntegrationsController::class )->register_routes(); } /** * @param array $schedules WordPress's schedules. * * @return array */ public function cron_schedule( array $schedules ): array { $schedules[ self::CRON_SCHEDULE ] = array( 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => __( 'Every five minutes (Plugixa CRM mail)', 'plugixa-crm' ), ); return $schedules; } public function registerHooks( Container $container ): void { add_filter( 'plugixa_crm_admin_i18n_strings', static fn( array $strings ): array => array_merge( $strings, Strings::all() ) ); add_action( self::CRON_HOOK, array( $this, 'drain' ) ); /* * HOW MANY CONNECTORS THERE ARE, BEFORE THE FIRST REQUEST. The sidebar is built before the app * can ask, and an Integrations page with nothing on it is a door to an empty room. The app * leaves the menu item out when this is zero. */ add_filter( 'plugixa_crm_admin_config', static function ( array $config ) use ( $container ): array { $config['integrationCount'] = count( $container->get( IntegrationRegistry::class )->all() ); return $config; } ); // Registered here as well as in four other modules: `cron_schedules` is a filter over one // shared array, so they all write the same key with the same value. Relying on another module // to have done it would be a dependency nothing declares. add_filter( 'cron_schedules', array( $this, 'cron_schedule' ) ); // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- five minutes, shared with the mail fetch and the webhook queue. add_action( 'init', static function (): void { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + 120, self::CRON_SCHEDULE, self::CRON_HOOK ); } } ); } /** * What must keep listening while this app is switched off — its half of purge, merge, erasure * and uninstall. See LifecycleInterface. * * @param Container $container The container. */ public function registerLifecycle( Container $container ): void { /* * A MERGE MUST CARRY THE LINKS ACROSS, and this is the seam the roadmap names by name (§3 * item 6). Merging two contacts trashes the loser; a link still pointing at it now points at * a record the CRM will not return, so the next sync finds nothing, concludes the external * customer is new, and creates a THIRD contact. The merge is undone by the integration, one * night later, and nobody connects the two events. * * IT IS A FILTER OVER A DECLARED LIST, NOT AN ACTION TO DO THE WORK IN. `MergeService` owns * the repointing — it runs one `UPDATE IGNORE` per registered table inside the merge, in the * right order and with the collision behaviour already reasoned about. A module that moved its * own rows in a listener would be a second mechanism racing the first, and the two would * disagree the first time either changed. */ add_filter( 'plugixa_crm_merge_references', static function ( array $references ): array { $references[] = array( 'table' => 'integration_links', 'id_column' => 'entity_id', 'type_column' => 'entity_type', ); return $references; } ); /* * A PURGED RECORD TAKES ITS LINKS WITH IT. `Uninstaller` drops whole tables and the trash * handles soft deletes, but a purge is neither: the record is gone for good, and a link that * outlived it is a row naming an external customer id against a CRM id that will never * resolve. It is also personal data about somebody who has been erased. */ add_action( 'plugixa_crm_before_purge', static function ( string $entity_type, int $id ) use ( $container ): void { $container->get( LinkRepository::class )->wipe_for_record( $entity_type, $id ); }, 10, 2 ); /* * GDPR. The link table names a person indirectly — "our contact 12 is their customer cus_9F2" * — so it belongs in the export and erasure sweep like any other table that mentions them. */ add_filter( 'plugixa_crm_personal_data_tables', static function ( array $tables ): array { $tables['integration_links'] = array( 'table' => 'integration_links', 'id_column' => 'entity_id', 'type_column' => 'entity_type', ); return $tables; } ); /* * UNINSTALL. `Uninstaller` drops tables by prefix, so both of ours go on their own — but it * knows nothing about options, and every connector's credentials are one. Sweeping by the * shared prefix means a connector that was never installed here still cannot leave a stored * API key behind on a site that once had it. */ add_action( 'plugixa_crm_uninstall', static function (): void { wp_clear_scheduled_hook( self::CRON_HOOK ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall; there is no API for "every option with this prefix". $names = $wpdb->get_col( $wpdb->prepare( // `%i` binds the table name too, so the statement is a constant literal. Order: table, then the LIKE pattern. 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( Credentials::PREFIX ) . '%' ) ); foreach ( (array) $names as $name ) { delete_option( (string) $name ); } } ); } /** * Drain the queue. The cron callback. * * WRAPPED, LIKE THE MAIL FETCH AND THE WEBHOOK QUEUE. An uncaught throw inside a cron callback * takes every other task in the same run with it, and this schedule is shared with four of them — * so one connector's broken dependency would stop reminders, campaigns and mail as well. */ public function drain(): void { try { Plugin::instance()->container()->get( JobRunner::class )->run(); } catch ( Throwable $e ) { unset( $e ); } } }