step_count = $step_count; $this->profile = $profile ?? new ContaiUserProfileService(); $this->jobs = $jobs ?? new ContaiJobRepository(); $this->websites = $websites ?? new ContaiWebsiteProvider(); // Injected rather than reached for statically inside `run()`: the stale // token clear is a step this class is responsible for getting right, and // a static call is a step no test can observe. $this->auth = $auth; } private function auth(): ContaiOnePlatformAuthService { if ( null === $this->auth ) { $this->auth = ContaiOnePlatformAuthService::create(); } return $this->auth; } public static function create(): self { return new self(); } /** * Activate the licence and queue the site build. * * @param array $payload { api_key: string, config: array }. * @return array { success: bool, step: string, message: string }. */ public function run( array $payload ): array { $api_key = isset( $payload['api_key'] ) ? sanitize_text_field( (string) $payload['api_key'] ) : ''; if ( '' === $api_key ) { return $this->fail( 'api_key', __( 'A licence key is required.', '1platform-content-ai' ) ); } // ── Licence, in the order the panel uses it. // // The stale-token clear is not optional and not cosmetic: a user token // minted for a PREVIOUS key stays valid-looking, so skipping this makes // the site authenticate as whoever owned the site before. if ( ! $this->profile->saveApiKey( $api_key ) ) { return $this->fail( 'api_key', __( 'The licence key could not be stored.', '1platform-content-ai' ) ); } $this->auth()->clearUserToken(); $profile = $this->profile->refreshUserProfile(); if ( empty( $profile['success'] ) ) { // Leaving a key that does not authenticate would show "connected" // on the licence screen forever, so it is removed on failure — // exactly what the panel does. $this->profile->deleteApiKey(); return $this->fail( 'profile', isset( $profile['message'] ) ? (string) $profile['message'] : __( 'The licence key could not be validated.', '1platform-content-ai' ) ); } // Registers this site against the owner's account. This is also the // call that makes the platform's heartbeat able to find the site later. $this->websites->ensureWebsiteExists(); // Publishes the site's plugin inventory now that a websiteId exists. // Never allowed to fail run(): a failed publish costs nothing more // than waiting for the next 1-minute poll cycle to catch it, while an // uncaught exception here would fail the licence activation itself. try { require_once __DIR__ . '/../plugins/ContaiPluginInventoryService.php'; ContaiPluginInventoryService::create()->push( 'bootstrap' ); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped contai_log( 'Bootstrap plugin inventory publish failed: ' . $e->getMessage() ); } // ── Wizard. $config = isset( $payload['config'] ) && is_array( $payload['config'] ) ? $payload['config'] : array(); if ( empty( $config ) ) { // A licensed site with no build queued is a legitimate outcome, not // a failure: the owner can still run the wizard by hand. return $this->ok( 'licence', __( 'Licence activated.', '1platform-content-ai' ) ); } // The same guard the form applies. Re-running the bootstrapper — a // retried provision, a second visit that raced the first — must not // queue a second build and bill the owner twice. if ( $this->jobs->findActiveSiteGenerationJob() ) { return $this->ok( 'generation', __( 'Site generation is already running.', '1platform-content-ai' ) ); } $this->persistWizardOptions( $config ); $job = ContaiJob::create( ContaiSiteGenerationJob::TYPE, $this->buildJobPayload( $config ), 0 ); $created = $this->jobs->create( $job ); if ( ! $created ) { return $this->fail( 'generation', __( 'Site generation could not be started.', '1platform-content-ai' ) ); } return $this->ok( 'generation', __( 'Site generation started.', '1platform-content-ai' ) ); } /** * The job payload, shaped exactly as the form builds it. * * The `progress` block is what the job runner reads to know where it is; * omitting it starts a job that believes it has no steps. */ private function buildJobPayload( array $config ): array { return array( 'config' => $this->resolveImageSource( $config ), 'progress' => array( 'current_step' => 0, 'current_step_name' => '', 'completed_steps' => array(), 'total_steps' => $this->stepCount(), 'started_at' => current_time( 'mysql' ), ), ); } /** * Translate the platform's neutral image-source code into the value the * generator actually takes. * * The translation lives HERE rather than on the platform side because the * platform's API contract is published: a provider name in that payload is a * provider name in the public OpenAPI document, which the ecosystem's own * leak gate rejects. This plugin already carries these names — its wizard's * `