readiness_score = $readiness_score; $this->schema_engine = $schema_engine; $this->validator = $validator; $this->feed_generator = $feed_generator; $this->crawler_monitor = $crawler_monitor; $this->robots_manager = $robots_manager; $this->manifest_endpoint = $manifest_endpoint; $this->feed_health_monitor = $feed_health_monitor; $this->content_optimizer = $content_optimizer; $this->container = $container; $this->bulk_queue = $bulk_queue; $this->image_alt_optimizer = $image_alt_optimizer; $this->attribution_tracker = $attribution_tracker; } /** * Register REST routes. * * @return void */ public function register_routes(): void { // --- Public discovery endpoints --- // These are intentionally public (permission_callback => __return_true): // they expose only non-sensitive discovery data — the same information a // crawler reads from robots.txt / llms.txt — so AI agents can find the // store's schema, feeds and capabilities without authentication. register_rest_route( self::NAMESPACE, '/status', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_status' ), // Admin-only: exposes exact WP/WooCommerce/PHP/plugin versions, // which should not be available to unauthenticated requests. 'permission_callback' => array( $this, 'check_admin_permission' ), ) ); register_rest_route( self::NAMESPACE, '/manifest', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_manifest' ), 'permission_callback' => '__return_true', // Public: read-only AI-discovery manifest. ) ); register_rest_route( self::NAMESPACE, '/openapi', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_openapi_spec' ), 'permission_callback' => '__return_true', // Public: read-only OpenAPI spec. ) ); // --- Authenticated endpoints --- register_rest_route( self::NAMESPACE, '/score', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_store_score' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ) ); register_rest_route( self::NAMESPACE, '/health', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_site_health' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ) ); register_rest_route( self::NAMESPACE, '/score/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_score' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'product_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'force' => array( 'required' => false, 'type' => 'boolean', 'default' => false, ), ), ) ); register_rest_route( self::NAMESPACE, '/score/refresh', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'refresh_scores' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Score refresh' ); }, ) ); register_rest_route( self::NAMESPACE, '/score/batch', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_batch_scores' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'product_ids' => array( 'required' => true, 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'maxItems' => 100, 'sanitize_callback' => static function ( $ids ): array { return array_map( 'absint', array_slice( (array) $ids, 0, 100 ) ); }, ), ), ) ); register_rest_route( self::NAMESPACE, '/schema/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_schema' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'product_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ) ); register_rest_route( self::NAMESPACE, '/validate/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'validate_schema' ), 'permission_callback' => array( $this, 'check_admin_permission' ), 'args' => array( 'product_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ) ); register_rest_route( self::NAMESPACE, '/settings', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_settings' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'update_settings' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ), ) ); // Feed endpoints. if ( null !== $this->feed_generator ) { register_rest_route( self::NAMESPACE, '/feeds', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_feeds' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ) ); register_rest_route( self::NAMESPACE, '/feeds/generate', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'trigger_feed_generation' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Feed generation' ); }, 'args' => array( 'force' => array( 'required' => false, 'type' => 'boolean', 'default' => false, ), ), ) ); register_rest_route( self::NAMESPACE, '/feeds/status', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_feed_status' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Feed status' ); }, ) ); register_rest_route( self::NAMESPACE, '/feeds/(?P[a-z]+)/regenerate', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'regenerate_single_feed' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Feed regeneration' ); }, 'args' => array( 'type' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), ), ) ); register_rest_route( self::NAMESPACE, '/feeds/(?P[a-z]+)/health', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_feed_health' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Feed health' ); }, 'args' => array( 'type' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), ), ) ); } // Crawler endpoints. if ( null !== $this->crawler_monitor ) { register_rest_route( self::NAMESPACE, '/crawler/logs', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_crawler_logs' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Crawler logs' ); }, 'args' => array( 'limit' => array( 'required' => false, 'type' => 'integer', 'default' => 50, 'sanitize_callback' => 'absint', ), 'offset' => array( 'required' => false, 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::NAMESPACE, '/crawler/stats', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_crawler_stats' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Crawler stats' ); }, 'args' => array( 'days' => array( 'required' => false, 'type' => 'integer', 'default' => 30, 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::NAMESPACE, '/crawler/rules', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_crawler_rules' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Crawler rules' ); }, ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'update_crawler_rules' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Crawler rules' ); }, ), ) ); } // Onboarding endpoint. register_rest_route( self::NAMESPACE, '/onboarding/complete', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'complete_onboarding' ), 'permission_callback' => array( $this, 'check_admin_permission' ), ) ); // Content optimizer endpoints. if ( null !== $this->content_optimizer ) { register_rest_route( self::NAMESPACE, '/content/optimize/(?P\d+)', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'optimize_content' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Content optimizer' ); }, 'args' => array( 'product_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'provider' => array( 'required' => false, 'type' => 'string', 'enum' => array( 'openai', 'anthropic', 'gemini' ), ), ), ) ); register_rest_route( self::NAMESPACE, '/content/activate/(?P\d+)', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'activate_content' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Content activation' ); }, 'args' => array( 'product_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::NAMESPACE, '/content/status/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_content_status' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Content status' ); }, 'args' => array( 'product_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); } // API key management endpoints. register_rest_route( self::NAMESPACE, '/settings/api-key/test', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'test_api_key' ), 'permission_callback' => static function () { return TierGate::require_pro( 'API key test' ); }, ) ); register_rest_route( self::NAMESPACE, '/settings/api-key/save', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_api_key' ), 'permission_callback' => static function () { return TierGate::require_pro( 'API key save' ); }, ) ); // Bulk optimization endpoints. if ( null !== $this->bulk_queue ) { register_rest_route( self::NAMESPACE, '/content/bulk/estimate', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'bulk_estimate' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Bulk optimizer' ); }, ) ); register_rest_route( self::NAMESPACE, '/content/bulk/start', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'bulk_start' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Bulk optimizer' ); }, ) ); register_rest_route( self::NAMESPACE, '/content/bulk/status/(?P[a-f0-9-]{36})', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'bulk_status' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Bulk status' ); }, ) ); register_rest_route( self::NAMESPACE, '/content/bulk/cancel/(?P[a-f0-9-]{36})', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'bulk_cancel' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Bulk optimizer' ); }, ) ); register_rest_route( self::NAMESPACE, '/content/bulk/pause/(?P[a-f0-9-]{36})', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'bulk_pause' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Bulk optimizer' ); }, ) ); register_rest_route( self::NAMESPACE, '/content/bulk/resume/(?P[a-f0-9-]{36})', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'bulk_resume' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Bulk optimizer' ); }, ) ); register_rest_route( self::NAMESPACE, '/content/bulk/history', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'bulk_history' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Bulk history' ); }, ) ); register_rest_route( self::NAMESPACE, '/content/products', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_content_products' ), 'permission_callback' => static function () { return TierGate::require_pro( 'Content products' ); }, 'args' => array( 'status' => array( 'required' => false, 'type' => 'string', 'default' => 'all', ), 'per_page' => array( 'required' => false, 'type' => 'integer', 'default' => 50, ), 'page' => array( 'required' => false, 'type' => 'integer', 'default' => 1, ), ), ) ); } // Analytics endpoints. register_rest_route( self::NAMESPACE, '/analytics/score', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_analytics_score' ), 'permission_callback' => static function () { return TierGate::require_business( 'Full GEO score' ); }, 'args' => array( 'force_refresh' => array( 'required' => false, 'type' => 'boolean', 'default' => false, ), ), ) ); register_rest_route( self::NAMESPACE, '/analytics/score/product/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_analytics_product_score' ), 'permission_callback' => static function () { return TierGate::require_business( 'Product score' ); }, 'args' => array( 'product_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::NAMESPACE, '/analytics/issues', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_analytics_issues' ), 'permission_callback' => static function () { return TierGate::require_business( 'Issue queue' ); }, ) ); register_rest_route( self::NAMESPACE, '/analytics/issues/(?P[a-z_]+)/fix', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'fix_analytics_issue' ), 'permission_callback' => static function () { return TierGate::require_business( 'Auto-fix' ); }, 'args' => array( 'issue_id' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), ), ) ); // Attribution endpoint (Business tier). if ( null !== $this->attribution_tracker ) { register_rest_route( self::NAMESPACE, '/analytics/attribution', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_analytics_attribution' ), 'permission_callback' => static function () { return TierGate::require_business( 'AI attribution' ); }, 'args' => array( 'days' => array( 'required' => false, 'type' => 'integer', 'default' => 30, ), ), ) ); } // White-label config endpoint (public — React self-configures). register_rest_route( self::NAMESPACE, '/whitelabel', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_whitelabel' ), 'permission_callback' => '__return_true', ) ); // White-label save endpoint (Agency tier, auth required). register_rest_route( self::NAMESPACE, '/whitelabel', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_whitelabel' ), 'permission_callback' => static function () { return TierGate::require_agency( 'White-label' ); }, ) ); // Agency client token generation. register_rest_route( self::NAMESPACE, '/agency/generate-client-token', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'generate_client_token' ), 'permission_callback' => static function () { return TierGate::require_agency( 'Agency client tokens' ); }, ) ); // Remote status endpoint (authenticated by bearer token from agency). register_rest_route( self::NAMESPACE, '/remote-status', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_remote_status' ), 'permission_callback' => array( $this, 'check_agency_token' ), ) ); } // ---------------------------------------------------------------- // Public endpoints // ---------------------------------------------------------------- /** * GET /status — Public health check. * * @return \WP_REST_Response */ public function get_status(): \WP_REST_Response { $modules = array( 'schema' => (bool) get_option( 'aicommerce_schema_enabled', true ), 'feeds' => null !== $this->feed_generator, 'crawler' => null !== $this->crawler_monitor, ); return new \WP_REST_Response( array( 'plugin_version' => AICOMMERCE_VERSION, 'modules' => $modules, 'wp_version' => get_bloginfo( 'version' ), 'wc_version' => defined( 'WC_VERSION' ) ? WC_VERSION : null, 'php_version' => PHP_VERSION, ), 200 ); } /** * GET /manifest — Public manifest data. * * @return \WP_REST_Response */ public function get_manifest(): \WP_REST_Response { if ( null === $this->manifest_endpoint ) { return ErrorFormatter::not_found( 'Manifest' ); } return new \WP_REST_Response( $this->manifest_endpoint->build_manifest(), 200 ); } /** * GET /openapi — Serve the OpenAPI spec as JSON. * * @return \WP_REST_Response */ public function get_openapi_spec(): \WP_REST_Response { $json_path = AICOMMERCE_PLUGIN_DIR . 'assets/openapi.json'; if ( file_exists( $json_path ) ) { global $wp_filesystem; if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } $raw = $wp_filesystem instanceof \WP_Filesystem_Base ? $wp_filesystem->get_contents( $json_path ) : ''; $spec = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( is_array( $spec ) ) { return new \WP_REST_Response( $spec, 200 ); } } return ErrorFormatter::not_found( 'OpenAPI spec' ); } // ---------------------------------------------------------------- // Score endpoints // ---------------------------------------------------------------- /** * GET /health — Run the store AI-readiness health check. * * @return \WP_REST_Response */ public function get_site_health(): \WP_REST_Response { global $wpdb; $health = new \AICommerce\Analytics\SiteHealth( $wpdb ); return new \WP_REST_Response( $health->run(), 200 ); } /** * GET /score — Get overall store AI readiness score. * * @return \WP_REST_Response */ public function get_store_score(): \WP_REST_Response { global $wpdb; $table = $wpdb->prefix . 'aicommerce_readiness_cache'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $row = $wpdb->get_row( "SELECT AVG(overall_score) as avg_score, COUNT(*) as product_count FROM {$table}", ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $avg_score = (int) round( (float) ( $row['avg_score'] ?? 0 ) ); $product_count = (int) ( $row['product_count'] ?? 0 ); $grade = match ( true ) { $avg_score >= 90 => 'A+', $avg_score >= 80 => 'A', $avg_score >= 70 => 'B', $avg_score >= 60 => 'C', $avg_score >= 50 => 'D', default => 'F', }; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $low_score_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE overall_score < %d", 70 ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return new \WP_REST_Response( array( 'overall_score' => $avg_score, 'grade' => $grade, 'product_count' => $product_count, 'issues' => $low_score_count, ), 200 ); } /** * GET /score/{product_id} — Get AI Readiness Score. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_score( \WP_REST_Request $request ): \WP_REST_Response { $product_id = $request->get_param( 'product_id' ); $force = $request->get_param( 'force' ); $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { return ErrorFormatter::not_found( 'Product' ); } if ( ! $force ) { $cached = $this->readiness_score->get_cached( $product_id ); if ( null !== $cached ) { $cached['from_cache'] = true; return new \WP_REST_Response( $cached, 200 ); } } $result = $this->readiness_score->calculate_and_cache( $product ); $result['from_cache'] = false; return new \WP_REST_Response( $result, 200 ); } /** * POST /score/refresh — Trigger recalculation of all product scores. * * @return \WP_REST_Response */ public function refresh_scores(): \WP_REST_Response { global $wpdb; $table = $wpdb->prefix . 'aicommerce_readiness_cache'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( 'aicommerce_recalculate_scores' ); } return new \WP_REST_Response( array( 'success' => true, 'message' => __( 'Score cache cleared. Recalculation scheduled.', 'ai-discoverability-kit' ), ), 200 ); } // ---------------------------------------------------------------- // Schema endpoints // ---------------------------------------------------------------- /** * GET /schema/{product_id} — Preview generated schema. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_schema( \WP_REST_Request $request ): \WP_REST_Response { $product_id = $request->get_param( 'product_id' ); $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { return ErrorFormatter::not_found( 'Product' ); } $schemas = $this->schema_engine->build_schemas( $product ); return new \WP_REST_Response( array( 'product_id' => $product_id, 'schemas' => $schemas, ), 200 ); } /** * GET /validate/{product_id} — Validate product schema. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function validate_schema( \WP_REST_Request $request ): \WP_REST_Response { $product_id = $request->get_param( 'product_id' ); $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { return ErrorFormatter::not_found( 'Product' ); } $schema = $this->schema_engine->get_product_schema()->generate( $product ); $validation = $this->validator->validate( $product_id, $schema ); return new \WP_REST_Response( array( 'product_id' => $product_id, 'validation' => $validation, ), 200 ); } // ---------------------------------------------------------------- // Batch endpoint // ---------------------------------------------------------------- /** * POST /score/batch — Get scores for multiple products (max 100). * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_batch_scores( \WP_REST_Request $request ): \WP_REST_Response { $product_ids = $request->get_param( 'product_ids' ); if ( count( $product_ids ) > 100 ) { return ErrorFormatter::error( 'batch_too_large', __( 'Maximum 100 products per batch request.', 'ai-discoverability-kit' ), 400 ); } $results = array(); foreach ( $product_ids as $product_id ) { $product_id = absint( $product_id ); $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { $results[ $product_id ] = array( 'code' => 'not_found', 'message' => __( 'Product not found.', 'ai-discoverability-kit' ), ); continue; } $cached = $this->readiness_score->get_cached( $product_id ); if ( null !== $cached ) { $results[ $product_id ] = $cached; } else { $results[ $product_id ] = $this->readiness_score->calculate_and_cache( $product ); } } return new \WP_REST_Response( $results, 200 ); } // ---------------------------------------------------------------- // Settings endpoints // ---------------------------------------------------------------- /** * GET /settings — Get plugin settings. * * @return \WP_REST_Response */ public function get_settings(): \WP_REST_Response { $settings = array( 'schema_enabled' => (bool) get_option( 'aicommerce_schema_enabled', true ), 'site_schema_enabled' => (bool) get_option( 'aicommerce_site_schema_enabled', true ), 'conflict_suppress' => (bool) get_option( 'aicommerce_conflict_suppress', true ), 'default_brand' => get_option( 'aicommerce_default_brand', '' ), 'llms_contact_email' => get_option( 'aicommerce_llms_contact_email', '' ), 'same_as_links' => get_option( 'aicommerce_same_as_links', array() ), 'site_type' => get_option( 'aicommerce_site_type', 'auto' ), 'business_type' => get_option( 'aicommerce_business_type', '' ), 'business_phone' => get_option( 'aicommerce_business_phone', '' ), 'business_address' => get_option( 'aicommerce_business_address', '' ), 'business_city' => get_option( 'aicommerce_business_city', '' ), 'business_region' => get_option( 'aicommerce_business_region', '' ), 'business_postal' => get_option( 'aicommerce_business_postal', '' ), 'business_country' => get_option( 'aicommerce_business_country', '' ), 'business_hours' => get_option( 'aicommerce_business_hours', '' ), 'business_price_range' => get_option( 'aicommerce_business_price_range', '' ), 'business_lat' => get_option( 'aicommerce_business_lat', '' ), 'business_lng' => get_option( 'aicommerce_business_lng', '' ), 'agency_token' => ! empty( get_option( 'aicommerce_agency_token', '' ) ), ); return new \WP_REST_Response( $settings, 200 ); } /** * POST /settings — Update plugin settings. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function update_settings( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_json_params(); $allowed = array( 'schema_enabled', 'site_schema_enabled', 'conflict_suppress', 'default_brand', 'llms_contact_email', 'same_as_links', 'site_type', 'business_type', 'business_phone', 'business_address', 'business_city', 'business_region', 'business_postal', 'business_country', 'business_hours', 'business_price_range', 'business_lat', 'business_lng', ); $updated = array(); foreach ( $allowed as $key ) { if ( ! array_key_exists( $key, $params ) ) { continue; } $option_key = 'aicommerce_' . $key; $value = $params[ $key ]; if ( in_array( $key, array( 'schema_enabled', 'site_schema_enabled', 'conflict_suppress' ), true ) ) { $value = (bool) $value; } elseif ( 'default_brand' === $key ) { $value = sanitize_text_field( $value ); } elseif ( 'llms_contact_email' === $key ) { $value = sanitize_email( (string) $value ); } elseif ( 'same_as_links' === $key && is_array( $value ) ) { $value = array_map( 'esc_url_raw', $value ); $value = array_filter( $value ); } elseif ( 'site_type' === $key ) { $allowed_types = array( 'auto', 'ecommerce', 'local_business', 'blog', 'organization' ); $value = in_array( $value, $allowed_types, true ) ? $value : 'auto'; } elseif ( 'business_hours' === $key ) { $value = sanitize_textarea_field( (string) $value ); } else { $value = sanitize_text_field( (string) $value ); } update_option( $option_key, $value ); $updated[ $key ] = $value; // Website type / product presence can change the detected schema entity. if ( in_array( $key, array( 'site_type', 'business_type' ), true ) ) { delete_transient( 'aicommerce_detected_site_type' ); } } // Handle agency token separately. if ( isset( $params['agency_token'] ) && is_string( $params['agency_token'] ) ) { $token = sanitize_text_field( $params['agency_token'] ); update_option( 'aicommerce_agency_token', $token, false ); $updated['agency_token'] = true; } // Handle MCP auth mode. if ( isset( $params['mcp_auth_mode'] ) && in_array( $params['mcp_auth_mode'], array( 'api_key', 'public' ), true ) ) { update_option( 'aicommerce_mcp_auth_mode', sanitize_text_field( $params['mcp_auth_mode'] ), false ); $updated['mcp_auth_mode'] = $params['mcp_auth_mode']; } // Handle Stripe ACP settings (encrypted). if ( null !== $this->container && $this->container->has( Encryption::class ) ) { /** @var Encryption $encryption */ $encryption = $this->container->get( Encryption::class ); $stripe_keys = array( 'stripe_publishable_key', 'stripe_secret_key', 'stripe_webhook_secret' ); foreach ( $stripe_keys as $stripe_key ) { if ( isset( $params[ $stripe_key ] ) && is_string( $params[ $stripe_key ] ) && '' !== $params[ $stripe_key ] ) { $encrypted = $encryption->encrypt( sanitize_text_field( $params[ $stripe_key ] ) ); update_option( 'aicommerce_' . $stripe_key, $encrypted, false ); $updated[ $stripe_key ] = true; } } } // Handle Stripe ACP enabled toggle. if ( isset( $params['stripe_acp_enabled'] ) ) { update_option( 'aicommerce_stripe_acp_enabled', (bool) $params['stripe_acp_enabled'] ); $updated['stripe_acp_enabled'] = (bool) $params['stripe_acp_enabled']; } return new \WP_REST_Response( array( 'updated' => $updated, 'message' => __( 'Settings saved.', 'ai-discoverability-kit' ), ), 200 ); } // ---------------------------------------------------------------- // Feed endpoints // ---------------------------------------------------------------- /** * GET /feeds — List registered feed formats and their files. * * @return \WP_REST_Response */ public function get_feeds(): \WP_REST_Response { $feeds = array(); $is_pro = aicommerce_is_pro(); foreach ( $this->feed_generator->get_feeds() as $type => $feed ) { if ( ! $is_pro ) { // Free users see feed type and status only — no URLs or details. $feeds[] = array( 'type' => $feed->get_type(), 'name' => $feed->get_name(), 'status' => 'locked', 'requires' => 'Pro', ); } else { $feeds[] = array( 'type' => $feed->get_type(), 'name' => $feed->get_name(), 'extension' => $feed->get_extension(), 'filename' => $feed->get_filename(), ); } } return new \WP_REST_Response( array( 'feeds' => $feeds ), 200 ); } /** * POST /feeds/generate — Schedule async feed generation. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function trigger_feed_generation( \WP_REST_Request $request ): \WP_REST_Response { $pro_gate = $this->require_pro( __( 'Feed generation', 'ai-discoverability-kit' ) ); if ( null !== $pro_gate ) { return $pro_gate; } $force = (bool) $request->get_param( 'force' ); $this->feed_generator->schedule_full_generation( $force ); return new \WP_REST_Response( array( 'message' => __( 'Feed generation scheduled.', 'ai-discoverability-kit' ), 'status' => 'generating', 'feed_types' => array_keys( $this->feed_generator->get_feeds() ), ), 202 ); } /** * GET /feeds/status — Get feed generation status. * * @return \WP_REST_Response */ public function get_feed_status(): \WP_REST_Response { return new \WP_REST_Response( array( 'feeds' => $this->feed_generator->get_status() ), 200 ); } /** * POST /feeds/{type}/regenerate — Regenerate a specific feed type. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function regenerate_single_feed( \WP_REST_Request $request ): \WP_REST_Response { $pro_gate = $this->require_pro( __( 'Feed regeneration', 'ai-discoverability-kit' ) ); if ( null !== $pro_gate ) { return $pro_gate; } $type = $request->get_param( 'type' ); $feeds = $this->feed_generator->get_feeds(); if ( ! isset( $feeds[ $type ] ) ) { return ErrorFormatter::not_found( 'Feed type' ); } $this->feed_generator->schedule_full_generation( true ); return new \WP_REST_Response( array( 'message' => sprintf( /* translators: %s: feed type */ __( 'Feed regeneration scheduled for %s.', 'ai-discoverability-kit' ), $type ), 'feed_type' => $type, 'status' => 'generating', ), 202 ); } /** * GET /feeds/{type}/health — Get health/validation for a specific feed. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_feed_health( \WP_REST_Request $request ): \WP_REST_Response { $pro_gate = $this->require_pro( __( 'Feed health monitoring', 'ai-discoverability-kit' ) ); if ( null !== $pro_gate ) { return $pro_gate; } $type = $request->get_param( 'type' ); $feeds = $this->feed_generator->get_feeds(); if ( ! isset( $feeds[ $type ] ) ) { return ErrorFormatter::not_found( 'Feed type' ); } $health = array( 'feed_type' => $type, 'status' => 'unknown', ); if ( null !== $this->feed_health_monitor ) { $health = $this->feed_health_monitor->check_feed( $feeds[ $type ] ); } return new \WP_REST_Response( $health, 200 ); } // ---------------------------------------------------------------- // Crawler endpoints // ---------------------------------------------------------------- /** * GET /crawler/logs — Get recent crawler log entries. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_crawler_logs( \WP_REST_Request $request ): \WP_REST_Response { $pro_gate = $this->require_pro( __( 'Crawler logs', 'ai-discoverability-kit' ) ); if ( null !== $pro_gate ) { return $pro_gate; } $limit = (int) $request->get_param( 'limit' ); $offset = (int) $request->get_param( 'offset' ); return new \WP_REST_Response( array( 'logs' => $this->crawler_monitor->get_logs( $limit, $offset ) ), 200 ); } /** * GET /crawler/stats — Get crawler visit statistics. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_crawler_stats( \WP_REST_Request $request ): \WP_REST_Response { $days = (int) $request->get_param( 'days' ); return new \WP_REST_Response( array( 'stats' => $this->crawler_monitor->get_stats( $days ) ), 200 ); } /** * GET /crawler/rules — Get current robots rules. * * @return \WP_REST_Response */ public function get_crawler_rules(): \WP_REST_Response { if ( null === $this->robots_manager ) { return new \WP_REST_Response( array( 'rules' => array() ), 200 ); } return new \WP_REST_Response( array( 'rules' => $this->robots_manager->get_rules(), 'blocked_bots' => get_option( 'aicommerce_blocked_bots', array() ), ), 200 ); } /** * POST /crawler/rules — Update crawler rules. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function update_crawler_rules( \WP_REST_Request $request ): \WP_REST_Response { $pro_gate = $this->require_pro( __( 'Crawler rule management', 'ai-discoverability-kit' ) ); if ( null !== $pro_gate ) { return $pro_gate; } $params = $request->get_json_params(); if ( isset( $params['block_bot'] ) ) { $this->crawler_monitor->block_bot( sanitize_text_field( $params['block_bot'] ) ); } if ( isset( $params['unblock_bot'] ) ) { $this->crawler_monitor->unblock_bot( sanitize_text_field( $params['unblock_bot'] ) ); } if ( null !== $this->robots_manager && isset( $params['bot_name'] ) ) { $allow = isset( $params['allow'] ) && is_array( $params['allow'] ) ? array_map( 'sanitize_text_field', $params['allow'] ) : array( '/' ); $disallow = isset( $params['disallow'] ) && is_array( $params['disallow'] ) ? array_map( 'sanitize_text_field', $params['disallow'] ) : array(); $this->robots_manager->update_rule( sanitize_text_field( $params['bot_name'] ), $allow, $disallow ); } return new \WP_REST_Response( array( 'message' => __( 'Crawler rules updated.', 'ai-discoverability-kit' ) ), 200 ); } // ---------------------------------------------------------------- // Onboarding // ---------------------------------------------------------------- /** * POST /onboarding/complete — Mark onboarding as complete. * * @return \WP_REST_Response */ public function complete_onboarding(): \WP_REST_Response { update_option( 'aicommerce_onboarding_complete', true, false ); /** * Fires when the onboarding wizard is completed. */ do_action( 'aicommerce/onboarding/complete' ); return new \WP_REST_Response( array( 'success' => true ), 200 ); } // ---------------------------------------------------------------- // Helpers // ---------------------------------------------------------------- /** * Check if Pro license is required and return error response if not available. * * @param string $feature_name The feature name for the error message. * * @return \WP_REST_Response|null Null if Pro (allow), response if not Pro (block). */ private function require_pro( string $feature_name ): ?\WP_REST_Response { if ( function_exists( 'aicommerce_is_pro' ) && ! aicommerce_is_pro() ) { return ErrorFormatter::pro_required( $feature_name ); } return null; } /** * Permission callback: require manage_woocommerce capability. * * @return bool|\WP_Error */ public function check_admin_permission() { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to access this endpoint.', 'ai-discoverability-kit' ), array( 'status' => 403 ) ); } return true; } // ---------------------------------------------------------------- // Content Optimizer endpoints // ---------------------------------------------------------------- /** * POST /content/optimize/{product_id} — Optimize product content. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function optimize_content( \WP_REST_Request $request ): \WP_REST_Response { $pro_gate = $this->require_pro( __( 'Content optimization', 'ai-discoverability-kit' ) ); if ( null !== $pro_gate ) { return $pro_gate; } $product_id = (int) $request->get_param( 'product_id' ); $provider = $request->get_param( 'provider' ); // Resolve provider-specific optimizer if requested. $optimizer = $this->content_optimizer; if ( ! empty( $provider ) && null !== $this->container ) { $optimizer = $this->resolve_optimizer_for_provider( $provider ); if ( null === $optimizer ) { return ErrorFormatter::error( 'invalid_provider', __( 'The specified AI provider is not configured.', 'ai-discoverability-kit' ), 400 ); } } try { $result = $optimizer->optimize_product( $product_id, true ); return new \WP_REST_Response( $result, 200 ); } catch ( \InvalidArgumentException $e ) { return ErrorFormatter::not_found( 'Product' ); } catch ( AIProviderException $e ) { return ErrorFormatter::error( 'ai_provider_error', $e->getMessage(), 502 ); } } /** * POST /content/activate/{product_id} — Toggle AI content fields. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function activate_content( \WP_REST_Request $request ): \WP_REST_Response { $pro_gate = $this->require_pro( __( 'Content activation', 'ai-discoverability-kit' ) ); if ( null !== $pro_gate ) { return $pro_gate; } $product_id = (int) $request->get_param( 'product_id' ); $params = $request->get_json_params(); $fields = isset( $params['fields'] ) && is_array( $params['fields'] ) ? array_map( 'sanitize_key', $params['fields'] ) : array(); $active = ! empty( $params['active'] ); $this->content_optimizer->set_field_active( $product_id, $fields, $active ); return new \WP_REST_Response( array( 'success' => true, 'active_fields' => $this->content_optimizer->get_active_fields( $product_id ), ), 200 ); } /** * GET /content/status/{product_id} — Get optimization status. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_content_status( \WP_REST_Request $request ): \WP_REST_Response { $product_id = (int) $request->get_param( 'product_id' ); $date = (int) get_post_meta( $product_id, ContentOptimizer::META_DATE, true ); $provider = (string) get_post_meta( $product_id, ContentOptimizer::META_PROVIDER, true ); $tokens = (int) get_post_meta( $product_id, ContentOptimizer::META_TOKENS, true ); return new \WP_REST_Response( array( 'has_optimization' => $date > 0, 'optimization_date' => $date > 0 ? gmdate( 'c', $date ) : null, 'provider' => $date > 0 ? $provider : null, 'active_fields' => $this->content_optimizer->get_active_fields( $product_id ), 'tokens_used' => $tokens, ), 200 ); } /** * POST /settings/api-key/test — Test an AI provider API key. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function test_api_key( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_json_params(); $provider = sanitize_key( $params['provider'] ?? '' ); if ( ! in_array( $provider, array( 'openai', 'anthropic', 'gemini' ), true ) ) { return ErrorFormatter::error( 'invalid_provider', __( 'Invalid provider. Use openai, anthropic, or gemini.', 'ai-discoverability-kit' ), 400 ); } if ( null === $this->container ) { return ErrorFormatter::error( 'not_configured', __( 'Service container not available.', 'ai-discoverability-kit' ), 500 ); } $provider_class = $this->get_provider_class( $provider ); if ( null === $provider_class || ! $this->container->has( $provider_class ) ) { return ErrorFormatter::error( 'provider_not_found', __( 'Provider not registered.', 'ai-discoverability-kit' ), 400 ); } /** @var \AICommerce\Content\Contracts\AIProviderInterface $ai_provider */ $ai_provider = $this->container->get( $provider_class ); $valid = $ai_provider->validate_api_key(); return new \WP_REST_Response( array( 'valid' => $valid ), 200 ); } /** * POST /settings/api-key/save — Save an encrypted AI provider API key. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function save_api_key( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_json_params(); $provider = sanitize_key( $params['provider'] ?? '' ); $api_key = $params['api_key'] ?? ''; if ( ! in_array( $provider, array( 'openai', 'anthropic', 'gemini' ), true ) ) { return ErrorFormatter::error( 'invalid_provider', __( 'Invalid provider.', 'ai-discoverability-kit' ), 400 ); } if ( empty( $api_key ) || ! is_string( $api_key ) ) { return ErrorFormatter::error( 'missing_key', __( 'API key is required.', 'ai-discoverability-kit' ), 400 ); } if ( null === $this->container || ! $this->container->has( Encryption::class ) ) { return ErrorFormatter::error( 'not_configured', __( 'Encryption not available.', 'ai-discoverability-kit' ), 500 ); } /** @var Encryption $encryption */ $encryption = $this->container->get( Encryption::class ); $encrypted = $encryption->encrypt( $api_key ); $option_key = 'aicommerce_' . $provider . '_api_key'; update_option( $option_key, $encrypted, false ); return new \WP_REST_Response( array( 'success' => true ), 200 ); } /** * Resolve a ContentOptimizer using a specific AI provider. * * @param string $provider Provider slug. * * @return ContentOptimizer|null */ private function resolve_optimizer_for_provider( string $provider ): ?ContentOptimizer { $provider_class = $this->get_provider_class( $provider ); if ( null === $provider_class || ! $this->container->has( $provider_class ) ) { return null; } $ai_provider = $this->container->get( $provider_class ); return new ContentOptimizer( $ai_provider, $this->container->get( \AICommerce\Feed\ProductDataExtractor::class ) ); } /** * Map provider slug to class name. * * @param string $provider Provider slug. * * @return string|null Fully qualified class name or null. */ private function get_provider_class( string $provider ): ?string { $map = array( 'openai' => \AICommerce\Content\OpenAIProvider::class, 'anthropic' => \AICommerce\Content\AnthropicProvider::class, 'gemini' => \AICommerce\Content\GeminiProvider::class, ); return $map[ $provider ] ?? null; } // ---------------------------------------------------------------- // Bulk optimization endpoints // ---------------------------------------------------------------- /** * POST /content/bulk/estimate — Get cost estimate without starting a batch. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function bulk_estimate( \WP_REST_Request $request ): \WP_REST_Response { $options = $request->get_json_params(); try { $estimate = $this->bulk_queue->get_cost_estimate( $options ); return new \WP_REST_Response( $estimate, 200 ); } catch ( \Exception $e ) { return ErrorFormatter::error( $e->getMessage(), 'bulk_estimate_error', 400 ); } } /** * POST /content/bulk/start — Start a bulk optimization batch. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function bulk_start( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_json_params(); if ( empty( $params['confirmed'] ) || true !== $params['confirmed'] ) { return ErrorFormatter::error( __( 'Batch start requires confirmation. Set confirmed: true.', 'ai-discoverability-kit' ), 'confirmation_required', 400 ); } $options = $params['options'] ?? $params; unset( $options['confirmed'] ); try { $batch_id = $this->bulk_queue->enqueue( $options ); $status = $this->bulk_queue->get_batch_status( $batch_id ); return new \WP_REST_Response( array( 'batch_id' => $batch_id, 'total_products' => $status['total_products'] ?? 0, 'cost_estimate_usd' => $status['cost_estimate_usd'] ?? 0, 'estimated_minutes' => (int) ceil( ( ( $status['total_products'] ?? 0 ) * 2 ) / 60 ), ), 201 ); } catch ( \Exception $e ) { return ErrorFormatter::error( $e->getMessage(), 'bulk_start_error', 400 ); } } /** * GET /content/bulk/status/{batch_id} — Get batch progress. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function bulk_status( \WP_REST_Request $request ): \WP_REST_Response { $batch_id = $request->get_param( 'batch_id' ); $status = $this->bulk_queue->get_batch_status( $batch_id ); if ( null === $status ) { return ErrorFormatter::not_found( __( 'Batch not found.', 'ai-discoverability-kit' ) ); } return new \WP_REST_Response( $status, 200 ); } /** * POST /content/bulk/cancel/{batch_id} — Cancel a running batch. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function bulk_cancel( \WP_REST_Request $request ): \WP_REST_Response { $batch_id = $request->get_param( 'batch_id' ); $cancelled = $this->bulk_queue->cancel_batch( $batch_id ); return new \WP_REST_Response( array( 'cancelled' => $cancelled ), $cancelled ? 200 : 400 ); } /** * POST /content/bulk/pause/{batch_id} — Pause a running batch. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function bulk_pause( \WP_REST_Request $request ): \WP_REST_Response { $batch_id = $request->get_param( 'batch_id' ); $paused = $this->bulk_queue->pause_batch( $batch_id ); return new \WP_REST_Response( array( 'paused' => $paused ), $paused ? 200 : 400 ); } /** * POST /content/bulk/resume/{batch_id} — Resume a paused batch. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function bulk_resume( \WP_REST_Request $request ): \WP_REST_Response { $batch_id = $request->get_param( 'batch_id' ); $resumed = $this->bulk_queue->resume_batch( $batch_id ); return new \WP_REST_Response( array( 'resumed' => $resumed ), $resumed ? 200 : 400 ); } /** * GET /content/bulk/history — Get recent batch history. * * @return \WP_REST_Response */ public function bulk_history(): \WP_REST_Response { $history = $this->bulk_queue->get_batch_history(); return new \WP_REST_Response( $history, 200 ); } /** * GET /content/products — List products with optimization status. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response */ public function get_content_products( \WP_REST_Request $request ): \WP_REST_Response { $status = $request->get_param( 'status' ) ?? 'all'; $per_page = min( (int) ( $request->get_param( 'per_page' ) ?? 50 ), 100 ); $page = max( (int) ( $request->get_param( 'page' ) ?? 1 ), 1 ); $args = array( 'status' => 'publish', 'limit' => $per_page, 'page' => $page, 'orderby' => 'ID', 'order' => 'DESC', 'return' => 'ids', ); $query = new \WC_Product_Query( $args ); $product_ids = $query->get_products(); $items = array(); foreach ( $product_ids as $pid ) { $opt_date = get_post_meta( $pid, ContentOptimizer::META_DATE, true ); $provider = get_post_meta( $pid, ContentOptimizer::META_PROVIDER, true ); $product_status = 'unoptimized'; if ( ! empty( $opt_date ) ) { $product_status = 'optimized'; } // Filter by requested status. if ( 'all' !== $status && $product_status !== $status ) { continue; } $items[] = array( 'product_id' => (int) $pid, 'title' => get_the_title( $pid ), 'status' => $product_status, 'optimized_on' => ! empty( $opt_date ) ? gmdate( 'Y-m-d H:i:s', (int) $opt_date ) : null, 'provider' => $provider ?: null, 'active_fields' => $this->content_optimizer ? $this->content_optimizer->get_active_fields( (int) $pid ) : array(), ); } return new \WP_REST_Response( $items, 200 ); } // ---------------------------------------------------------------- // Analytics endpoints // ---------------------------------------------------------------- /** * GET /analytics/score — Get store-wide GEO readiness score. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_analytics_score( \WP_REST_Request $request ): \WP_REST_Response { $force = (bool) $request->get_param( 'force_refresh' ); $result = $this->readiness_score->calculate_store_score( $force ); // Add score history for dashboard trend chart. $history = get_option( 'aicommerce_score_history', array() ); $thirty_ago = gmdate( 'Y-m-d', strtotime( '-30 days' ) ); $history_arr = array(); foreach ( $history as $date => $score ) { if ( $date >= $thirty_ago ) { $history_arr[] = array( 'date' => $date, 'score' => (int) $score, ); } } $result['history'] = $history_arr; return new \WP_REST_Response( $result, 200 ); } /** * GET /analytics/score/product/{product_id} — Get per-product score. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_analytics_product_score( \WP_REST_Request $request ): \WP_REST_Response { $product_id = (int) $request->get_param( 'product_id' ); $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { return ErrorFormatter::not_found( 'Product' ); } $score = $this->readiness_score->calculate_product_score( $product_id ); return new \WP_REST_Response( array( 'product_id' => $product_id, 'score' => $score, ), 200 ); } /** * GET /analytics/issues — Get prioritized issue list. * * @return \WP_REST_Response */ public function get_analytics_issues(): \WP_REST_Response { $issues = $this->readiness_score->get_issues(); return new \WP_REST_Response( $issues, 200 ); } /** * POST /analytics/issues/{issue_id}/fix — Auto-fix an issue. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function fix_analytics_issue( \WP_REST_Request $request ): \WP_REST_Response { $issue_id = $request->get_param( 'issue_id' ); if ( 'gptbot_blocked' === $issue_id && null !== $this->robots_manager ) { $this->robots_manager->update_rule( 'GPTBot', array( '/' ), array() ); return new \WP_REST_Response( array( 'success' => true, 'message' => __( 'GPTBot has been allowed in robots.txt.', 'ai-discoverability-kit' ), ), 200 ); } if ( 'schema_conflict' === $issue_id ) { update_option( 'aicommerce_conflict_suppress', true ); return new \WP_REST_Response( array( 'success' => true, 'message' => __( 'Schema conflict suppression enabled.', 'ai-discoverability-kit' ), ), 200 ); } return ErrorFormatter::error( 'not_auto_fixable', __( 'This issue cannot be auto-fixed.', 'ai-discoverability-kit' ), 400 ); } /** * GET /analytics/attribution — Get AI attribution summary. * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function get_analytics_attribution( \WP_REST_Request $request ): \WP_REST_Response { // Business tier gate. if ( function_exists( 'aicommerce_is_business' ) && ! aicommerce_is_business() ) { return ErrorFormatter::error( 'business_required', __( 'AI attribution requires the Business plan.', 'ai-discoverability-kit' ), 403 ); } $days = (int) $request->get_param( 'days' ); $summary = $this->attribution_tracker->get_summary( $days ); return new \WP_REST_Response( $summary, 200 ); } // ---------------------------------------------------------------- // White-label & Agency endpoints // ---------------------------------------------------------------- /** * GET /whitelabel — Get white-label configuration. * * @return \WP_REST_Response */ public function get_whitelabel(): \WP_REST_Response { $whitelabel = new WhiteLabel(); return new \WP_REST_Response( $whitelabel->get_config(), 200 ); } /** * POST /whitelabel — Save white-label settings (Agency tier). * * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response */ public function save_whitelabel( \WP_REST_Request $request ): \WP_REST_Response { if ( ! function_exists( 'aicommerce_is_agency' ) || ! aicommerce_is_agency() ) { return ErrorFormatter::error( 'agency_required', __( 'White-label requires the Agency plan.', 'ai-discoverability-kit' ), 403 ); } $whitelabel = new WhiteLabel(); $whitelabel->save_config( $request->get_json_params() ); return new \WP_REST_Response( $whitelabel->get_config(), 200 ); } /** * POST /agency/generate-client-token — Generate a client connection token. * * @return \WP_REST_Response */ public function generate_client_token(): \WP_REST_Response { if ( ! function_exists( 'aicommerce_is_agency' ) || ! aicommerce_is_agency() ) { return ErrorFormatter::error( 'agency_required', __( 'Client management requires the Agency plan.', 'ai-discoverability-kit' ), 403 ); } $token = wp_generate_password( 64, false ); $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( 365 * DAY_IN_SECONDS ) ); $tokens = get_option( 'aicommerce_agency_client_tokens', array() ); $tokens[] = array( 'token' => hash( 'sha256', $token ), 'created_at' => gmdate( 'Y-m-d H:i:s' ), 'expires_at' => $expires_at, ); update_option( 'aicommerce_agency_client_tokens', $tokens, false ); return new \WP_REST_Response( array( 'token' => $token, 'expires_at' => $expires_at, ), 200 ); } /** * GET /remote-status — Return site status for agency dashboard. * * @return \WP_REST_Response */ public function get_remote_status(): \WP_REST_Response { $score_data = $this->readiness_score->calculate_store_score(); $feed_count = 0; $last_feed = null; $feed_types = get_option( 'aicommerce_feed_types', array() ); if ( is_array( $feed_types ) ) { $feed_count = count( $feed_types ); foreach ( $feed_types as $feed ) { $name = $feed['type'] ?? $feed; $last = get_option( "aicommerce_feed_{$name}_last_generated", '' ); if ( $last && ( null === $last_feed || $last > $last_feed ) ) { $last_feed = $last; } } } $issues = $this->readiness_score->get_issues(); $revenue = null; if ( function_exists( 'aicommerce_is_business' ) && aicommerce_is_business() && null !== $this->attribution_tracker ) { $attribution = $this->attribution_tracker->get_summary( 30 ); $revenue = $attribution['total_revenue'] ?? 0.0; } return new \WP_REST_Response( array( 'site_url' => home_url(), 'site_name' => get_bloginfo( 'name' ), 'score' => $score_data['overall'] ?? 0, 'active_modules' => $this->get_active_module_list(), 'feed_count' => $feed_count, 'last_feed_generated' => $last_feed, 'issue_count' => count( $issues ), 'ai_revenue_30d' => $revenue, 'plugin_version' => AICOMMERCE_VERSION, ), 200 ); } /** * Check agency bearer token for remote-status endpoint. * * @param \WP_REST_Request $request The request. * * @return bool|\WP_Error */ public function check_agency_token( \WP_REST_Request $request ) { $auth_header = $request->get_header( 'Authorization' ); if ( empty( $auth_header ) || ! str_starts_with( $auth_header, 'Bearer ' ) ) { return new \WP_Error( 'rest_unauthorized', __( 'Missing or invalid Authorization header.', 'ai-discoverability-kit' ), array( 'status' => 401 ) ); } $token = substr( $auth_header, 7 ); $token_hash = hash( 'sha256', $token ); // Check stored agency token (client-side). $stored_token = get_option( 'aicommerce_agency_token', '' ); if ( ! empty( $stored_token ) && hash_equals( hash( 'sha256', $stored_token ), $token_hash ) ) { return true; } return new \WP_Error( 'rest_forbidden', __( 'Invalid agency token.', 'ai-discoverability-kit' ), array( 'status' => 403 ) ); } /** * Get list of active module names. * * @return string[] */ private function get_active_module_list(): array { $modules = array( 'schema' ); if ( null !== $this->feed_generator ) { $modules[] = 'feeds'; } if ( null !== $this->crawler_monitor ) { $modules[] = 'crawler'; } if ( null !== $this->content_optimizer ) { $modules[] = 'content_optimizer'; } if ( null !== $this->attribution_tracker ) { $modules[] = 'attribution'; } return $modules; } }