0 ? substr( sanitize_text_field( trim( $device_id ) ), 0, 255 ) : null; $meta = self::sanitize_metadata( $metadata ); $url = rtrim( $registry_url, '/' ) . '/api/v1/analytics/event'; $body = array( 'event_type' => $event_type, 'resource_id' => $resource_id, 'resource_title' => $resource_title, 'device_type' => $device_type, 'metadata' => $meta, ); if ( $device_id !== null ) { $body['device_id'] = $device_id; } wp_remote_post( $url, array( 'timeout' => 2, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json', 'X-PressNative-API-Key' => $api_key, ), 'body' => wp_json_encode( $body ), ) ); return true; } /** * Fetches JSON from the Registry analytics API (for dashboard proxy). * * @param string $path Path and query. * @return array|null */ private static function fetch_from_registry( $path ) { $api_key = get_option( PressNative_Admin::OPTION_API_KEY, '' ); $registry_url = PressNative_Admin::get_registry_url(); if ( ! $api_key || ! $registry_url ) { return null; } $url = rtrim( $registry_url, '/' ) . $path; $response = wp_remote_get( $url, array( 'timeout' => 5, 'headers' => array( 'X-PressNative-API-Key' => $api_key ), ) ); if ( is_wp_error( $response ) ) { return null; } if ( wp_remote_retrieve_response_code( $response ) !== 200 ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); return is_array( $data ) ? $data : null; } /** * Handles POST /track: forwards the view event to the Registry. * * @param WP_REST_Request $request Request with JSON body. * @return WP_REST_Response|WP_Error */ public static function handle_track( WP_REST_Request $request ) { // Nothing is stored locally; events are only forwarded once the site is connected. if ( '' === (string) get_option( PressNative_Admin::OPTION_API_KEY, '' ) ) { return rest_ensure_response( array( 'ok' => false ) ); } if ( ! PressNative_Rate_Limit::allow( 'track', 120, MINUTE_IN_SECONDS ) ) { return PressNative_Rate_Limit::error(); } $params = $request->get_json_params(); if ( ! is_array( $params ) ) { $params = array(); } $event_type = isset( $params['event_type'] ) ? self::normalize_event_type( $params['event_type'] ) : ''; $resource_id = isset( $params['resource_id'] ) ? sanitize_text_field( $params['resource_id'] ) : ''; $resource_title = isset( $params['resource_title'] ) ? sanitize_text_field( $params['resource_title'] ) : null; $device_type = isset( $params['device_type'] ) ? sanitize_text_field( $params['device_type'] ) : null; $device_id = isset( $params['device_id'] ) ? sanitize_text_field( $params['device_id'] ) : null; $metadata = isset( $params['metadata'] ) ? $params['metadata'] : null; $valid_types = self::get_valid_event_types(); if ( ! in_array( $event_type, $valid_types, true ) ) { return new WP_Error( 'invalid_event_type', __( 'Invalid event_type.', 'pressnative-apps' ), array( 'status' => 400 ) ); } $ok = self::forward_event_to_registry( $event_type, $resource_id, $resource_title, $device_type, $device_id, $metadata, true ); return rest_ensure_response( array( 'ok' => $ok ) ); } /** * Registers REST routes: track (forward to Registry) and dashboard (proxy to Registry). * * @return void */ public static function register_rest_routes() { $permission = function () { return current_user_can( 'manage_options' ); }; $valid_types = self::get_valid_event_types(); // Also accept legacy hyphenated product-category in REST enum. $track_enum = array_values( array_unique( array_merge( $valid_types, array( 'product-category' ) ) ) ); register_rest_route( 'pressnative/v1', '/track', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'handle_track' ), // Public: app view events; rate-limited and forwarded only when connected. 'permission_callback' => '__return_true', 'args' => array( 'event_type' => array( 'required' => true, 'type' => 'string', 'enum' => $track_enum, 'sanitize_callback' => 'sanitize_text_field', ), 'resource_id' => array( 'required' => false, 'type' => 'string', 'default' => '' ), 'resource_title' => array( 'required' => false, 'type' => 'string', 'default' => null ), 'device_type' => array( 'required' => false, 'type' => 'string', 'enum' => array( self::DEVICE_IOS, self::DEVICE_ANDROID, self::DEVICE_UNKNOWN ), 'default' => null, ), 'device_id' => array( 'required' => false, 'type' => 'string', 'default' => null ), 'metadata' => array( 'required' => false, 'type' => 'object', 'default' => null ), ), ) ); register_rest_route( 'pressnative/v1', '/analytics/summary', array( 'methods' => WP_REST_Server::READABLE, 'callback' => function ( WP_REST_Request $request ) { $days = (int) $request->get_param( 'days' ); $days = $days >= 1 && $days <= 365 ? $days : 30; $data = self::fetch_from_registry( '/api/v1/analytics/summary?days=' . $days ); return rest_ensure_response( $data !== null ? $data : array( 'total' => 0, 'by_type' => array( 'home' => 0, 'post' => 0, 'page' => 0, 'category' => 0, 'search' => 0, 'shop' => 0, 'product' => 0, 'product_category' => 0, ), 'favorites' => 0, 'push_received' => 0, 'push_clicked' => 0, ) ); }, 'permission_callback' => $permission, 'args' => array( 'days' => array( 'default' => 30, 'type' => 'integer', 'minimum' => 1, 'maximum' => 365 ) ), ) ); $proxy_routes = array( '/analytics/top-posts' => array(), '/analytics/top-pages' => array(), '/analytics/top-categories' => array(), '/analytics/top-searches' => array(), '/analytics/views-over-time' => array( 'group_by' => true ), '/analytics/device-breakdown' => array( 'default' => array( 'ios' => 0, 'android' => 0, 'unknown' => 0 ) ), '/analytics/engagement' => array( 'default' => array( 'events' => 0, 'top_posts' => array() ) ), '/analytics/commerce' => array( 'default' => array( 'product_views' => 0, 'purchases' => 0, 'top_products' => array() ) ), '/analytics/funnel' => array( 'default' => array( 'push_received' => 0, 'push_clicked' => 0 ) ), ); foreach ( $proxy_routes as $route => $opts ) { register_rest_route( 'pressnative/v1', $route, array( 'methods' => WP_REST_Server::READABLE, 'callback' => function ( WP_REST_Request $request ) use ( $route, $opts ) { $days = (int) $request->get_param( 'days' ); $days = $days >= 1 && $days <= 365 ? $days : 30; $path = '/api/v1' . $route . '?days=' . $days; if ( ! empty( $opts['group_by'] ) ) { $group_by = $request->get_param( 'group_by' ) === 'week' ? 'week' : 'day'; $path .= '&group_by=' . $group_by; } if ( in_array( $route, array( '/analytics/top-posts', '/analytics/top-pages', '/analytics/top-categories', '/analytics/top-searches' ), true ) ) { $limit = (int) $request->get_param( 'limit' ); $limit = $limit >= 1 && $limit <= 100 ? $limit : 10; $path .= '&limit=' . $limit; } $data = self::fetch_from_registry( $path ); $default = isset( $opts['default'] ) ? $opts['default'] : array(); return rest_ensure_response( is_array( $data ) ? $data : $default ); }, 'permission_callback' => $permission, 'args' => array( 'days' => array( 'default' => 30, 'type' => 'integer', 'minimum' => 1, 'maximum' => 365 ), 'limit' => array( 'default' => 10, 'type' => 'integer', 'minimum' => 1, 'maximum' => 100 ), 'group_by' => array( 'default' => 'day', 'enum' => array( 'day', 'week' ) ), ), ) ); } } }