'GET', 'callback' => [ $this, 'get_products' ], 'permission_callback' => [ $this, 'check_permission' ], 'args' => [ 'page' => [ 'default' => 1, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'per_page' => [ 'default' => 20, 'type' => 'integer', 'sanitize_callback' => 'absint', ], 'status' => [ 'type' => 'string', 'enum' => [ 'protected', 'not-protected' ], ], 'visibility' => [ 'type' => 'string', 'enum' => [ 'visible', 'hidden' ], ], 'failing_check' => [ 'type' => 'string', ], 'sort_column' => [ 'type' => 'string', 'enum' => [ 'created_at', 'readiness_score' ], 'default' => 'created_at', ], 'sort_order' => [ 'type' => 'string', 'enum' => [ 'asc', 'desc' ], 'default' => 'desc', ], ], ] ); register_rest_route( self::NAMESPACE, '/protection/products-by-ids', [ 'methods' => 'GET', 'callback' => [ $this, 'get_products_by_ids' ], 'permission_callback' => [ $this, 'check_permission' ], 'args' => [ 'product_ids' => [ 'required' => true, 'type' => 'string', 'description' => 'Comma-separated local product IDs', ], ], ] ); register_rest_route( self::NAMESPACE, '/protection/summary', [ 'methods' => 'GET', 'callback' => [ $this, 'get_protection_summary' ], 'permission_callback' => [ $this, 'check_permission' ], ] ); register_rest_route( self::NAMESPACE, '/protection/bulk-status', [ 'methods' => 'GET', 'callback' => [ $this, 'get_bulk_status' ], 'permission_callback' => [ $this, 'check_permission' ], 'args' => [ 'product_ids' => [ 'required' => true, 'type' => 'string', 'description' => 'Comma-separated product IDs', ], ], ] ); } /** * GET /mintall/v1/protection/summary * * Powers the dashboard auto-protect banner: an X-of-Y rollup of AUTO jobs * since auto-protect was last enabled. Returns auto_protect=false (and no * summary) when the toggle is off or the backend is unreachable, so the * dashboard can simply skip the banner. */ public function get_protection_summary() { $auto_protect = (bool) get_option( 'mintall_keep_auto_protect', false ); $since = get_option( 'mintall_keep_auto_protect_enabled_at', '' ) ?: null; $response = [ 'auto_protect' => $auto_protect, 'since' => $since, 'summary' => null, ]; if ( ! $auto_protect ) { return new \WP_REST_Response( $response ); } $api_key = $this->get_api_key(); if ( is_wp_error( $api_key ) ) { return new \WP_REST_Response( $response ); } $summary = Mintall_Protection::summary( $api_key, 'AUTO', $since ); if ( ! is_wp_error( $summary ) && is_array( $summary ) ) { $response['summary'] = $summary; } return new \WP_REST_Response( $response ); } /** * GET /mintall/v1/protection/products * * Lists the products synced to Mintall (the platform's product records are * the source of truth) together with their live protection status. We page * the platform list API by offset/limit and, for just the page's product * ids, ask the backend queue for status (one query, capped at 100 ids by * per_page). A store with nothing synced returns an empty list, which the * UI renders as a "sync to get started" empty state. * * The platform list API returns no total count, so we fetch one extra item * to know whether a next page exists (has_more), mirroring the Shopify app. */ public function get_products( $request ) { $page = max( 1, $request->get_param( 'page' ) ); // Cap at 99 so the has-more probe (per_page + 1) stays within the // platform list API's limit ceiling of 100. $per_page = min( 99, max( 1, $request->get_param( 'per_page' ) ) ); $offset = ( $page - 1 ) * $per_page; $empty = [ 'items' => [], 'page' => $page, 'per_page' => $per_page, 'has_more' => false, ]; $api_key = $this->get_api_key(); if ( is_wp_error( $api_key ) ) { // Not connected — nothing is synced to Mintall. return new \WP_REST_Response( $empty ); } $list_params = [ 'offset' => $offset, 'limit' => $per_page + 1, 'sort_column' => $request->get_param( 'sort_column' ), 'sort_order' => $request->get_param( 'sort_order' ), ]; $status = $request->get_param( 'status' ); if ( 'protected' === $status ) { $list_params['has_assets'] = 'true'; } elseif ( 'not-protected' === $status ) { $list_params['has_assets'] = 'false'; } $visibility = $request->get_param( 'visibility' ); if ( 'visible' === $visibility ) { $list_params['is_visible'] = 'true'; } elseif ( 'hidden' === $visibility ) { $list_params['is_visible'] = 'false'; } $failing_check = $request->get_param( 'failing_check' ); if ( $failing_check ) { $list_params['failing_check'] = $failing_check; } $products = Mintall_Products::list_products( $api_key, $list_params ); if ( is_wp_error( $products ) ) { return $this->error_response( $products->get_error_message(), 502 ); } $has_more = count( $products ) > $per_page; $products = array_slice( $products, 0, $per_page ); $product_ids = []; foreach ( $products as $p ) { $ext = $p['external_product_id'] ?? null; if ( null !== $ext && '' !== $ext ) { $product_ids[] = (int) $ext; } } $status_map = $this->fetch_status_map( $product_ids ); $items = []; foreach ( $products as $p ) { $ext = $p['external_product_id'] ?? null; if ( null === $ext || '' === $ext ) { continue; } $id = (int) $ext; $product = wc_get_product( $id ); $meta = is_array( $p['product_metadata'] ?? null ) ? $p['product_metadata'] : []; $entry = $status_map[ (string) $id ] ?? null; $items[] = [ 'product_id' => $id, 'product_name' => $product ? $product->get_name() : ( $meta['title'] ?? '(Deleted)' ), 'product_url' => $product ? get_edit_post_link( $id, 'raw' ) : '', 'product_image' => $this->resolve_product_image( $product, $meta ), 'status' => $entry['status'] ?? 'UNPROTECTED', 'counts' => $entry['counts'] ?? null, ]; } return new \WP_REST_Response( [ 'items' => $items, 'page' => $page, 'per_page' => $per_page, 'has_more' => $has_more, ] ); } /** * GET /mintall/v1/protection/products-by-ids?product_ids=1,2,3 * * Looks products up by local WooCommerce product id rather than by paging * through the platform's synced list — powers BulkProtectPanel when it's * opened from the native product list's bulk action, where the selection * may include products that aren't (yet) synced to Mintall. Ids that no * longer resolve to a WooCommerce product are silently skipped, matching * the not_found skip already surfaced by POST /mint/bulk. */ public function get_products_by_ids( $request ) { $ids_param = $request->get_param( 'product_ids' ); $product_ids = array_filter( array_map( 'absint', explode( ',', $ids_param ) ) ); $status_map = $this->fetch_status_map( $product_ids ); $items = []; foreach ( $product_ids as $id ) { $product = wc_get_product( $id ); if ( ! $product ) { continue; } $entry = $status_map[ (string) $id ] ?? null; $items[] = [ 'product_id' => $id, 'product_name' => $product->get_name(), 'product_url' => get_edit_post_link( $id, 'raw' ), 'product_image' => $this->resolve_product_image( $product, [] ), 'status' => $entry['status'] ?? 'UNPROTECTED', 'counts' => $entry['counts'] ?? null, ]; } return new \WP_REST_Response( [ 'items' => $items ] ); } /** * GET /mintall/v1/protection/bulk-status?product_ids=1,2,3 * * Batch protection-status read for the bulk-protect progress panel, keyed * by product id so the frontend can update each row's bucket independently * as jobs complete. */ public function get_bulk_status( $request ) { $ids_param = $request->get_param( 'product_ids' ); $product_ids = array_filter( array_map( 'absint', explode( ',', $ids_param ) ) ); $map = $this->fetch_status_map( $product_ids ); $products = []; foreach ( $product_ids as $id ) { $entry = $map[ (string) $id ] ?? null; $products[ $id ] = [ 'status' => $entry['status'] ?? 'UNPROTECTED', 'counts' => $entry['counts'] ?? null, ]; } return new \WP_REST_Response( [ 'products' => $products ] ); } /** * Resolve a thumbnail for a row: prefer the local WC image, fall back to the * featured image stored in the platform's product_metadata (so deleted or * not-yet-imaged local products still show whatever Mintall has). * * @param \WC_Product|false $product * @param array $meta product_metadata from the platform. * @return string */ private function resolve_product_image( $product, $meta ) { if ( $product ) { $url = wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' ); if ( $url ) { return $url; } } $featured = $meta['featured_image'] ?? null; if ( is_array( $featured ) && ! empty( $featured['url'] ) ) { return (string) $featured['url']; } return ''; } /** * Look up backend protection status for a page of product ids, keyed by id. * * Returns an empty map (every product falls back to UNPROTECTED) when not * connected or the backend is unreachable, so the list still renders. * Chunks in groups of 100 since Mintall_Protection::status() caps at 100 * ids per call — /protection/products stays within that today (per_page is * capped at 99), but /protection/bulk-status has no such ceiling on its * input, so this keeps both callers correct regardless of how many ids * they pass. * * @param int[] $product_ids * @return array external_product_id => { status, counts } */ private function fetch_status_map( $product_ids ) { if ( empty( $product_ids ) ) { return []; } $api_key = $this->get_api_key(); if ( is_wp_error( $api_key ) ) { return []; } $map = []; foreach ( array_chunk( array_map( 'strval', $product_ids ), 100 ) as $chunk ) { $result = Mintall_Protection::status( $api_key, $chunk ); if ( is_wp_error( $result ) || empty( $result['products'] ) || ! is_array( $result['products'] ) ) { continue; } foreach ( $result['products'] as $entry ) { $map[ (string) ( $entry['external_product_id'] ?? '' ) ] = $entry; } } return $map; } }