Commit 4e7edcbf02 for woocommerce
commit 4e7edcbf0228f9319b35dafd1872344f43009ec8
Author: Mike Jolley <mike.jolley@me.com>
Date: Tue Sep 16 14:50:05 2025 +0100
[Rest API v4] Order Notes Controller (#60898)
* Add order notes endpoint under v4 namespace
* changelog
* Implement utils and improve how schema is returned in controllers
* Fix non namespaced v4 endpoints until migrated
* Remove cogs function change
* Remove order link since they do not yet exist
* Fix datetime format
* Harden note lookup
* Simplify permissions checks
* Remove unused imports
* Add get_item_response for controllers to extend
* Remove special author handling
* Mark utils as internal use
* Correct collection url
* Abstract collection params
diff --git a/plugins/woocommerce/changelog/wooprd-847-create-v4-order-notes-controller b/plugins/woocommerce/changelog/wooprd-847-create-v4-order-notes-controller
new file mode 100644
index 0000000000..6061842007
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-847-create-v4-order-notes-controller
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Add v4 order-notes route behind feature flag
+
+
diff --git a/plugins/woocommerce/includes/rest-api/Server.php b/plugins/woocommerce/includes/rest-api/Server.php
index c3ac50188d..38b95c3816 100644
--- a/plugins/woocommerce/includes/rest-api/Server.php
+++ b/plugins/woocommerce/includes/rest-api/Server.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\RestApi\Utilities\SingletonTrait;
use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes\Controller as OrderNotesController;
/**
* Class responsible for loading the REST API and all REST API namespaces.
@@ -43,10 +44,13 @@ class Server {
$legacy_proxy = $container->get( LegacyProxy::class );
foreach ( $this->get_rest_namespaces() as $namespace => $controllers ) {
foreach ( $controllers as $controller_name => $controller_class ) {
- $this->controllers[ $namespace ][ $controller_name ] =
- $container->has( $controller_class ) ?
+ if ( 'wc/v4' === $namespace && ! str_starts_with( $controller_class, 'WC_REST_' ) ) {
+ $this->controllers[ $namespace ][ $controller_name ] = $this->get_v4_controller( $controller_name, $controller_class );
+ } else {
+ $this->controllers[ $namespace ][ $controller_name ] = $container->has( $controller_class ) ?
$container->get( $controller_class ) :
$legacy_proxy->get_instance_of( $controller_class );
+ }
$this->controllers[ $namespace ][ $controller_name ]->register_routes();
}
}
@@ -58,22 +62,24 @@ class Server {
* @return array List of Namespaces and Main controller classes.
*/
protected function get_rest_namespaces() {
+ $namespaces = array(
+ 'wc/v1' => wc_rest_should_load_namespace( 'wc/v1' ) ? $this->get_v1_controllers() : array(),
+ 'wc/v2' => wc_rest_should_load_namespace( 'wc/v2' ) ? $this->get_v2_controllers() : array(),
+ 'wc/v3' => wc_rest_should_load_namespace( 'wc/v3' ) ? $this->get_v3_controllers() : array(),
+ 'wc-telemetry' => wc_rest_should_load_namespace( 'wc-telemetry' ) ? $this->get_telemetry_controllers() : array(),
+ );
+
+ if ( wc_rest_should_load_namespace( 'wc/v4' ) && Features::is_enabled( 'rest-api-v4' ) ) {
+ $namespaces['wc/v4'] = $this->get_v4_controllers();
+ }
+
/**
* Filter the list of REST API controllers to load.
*
* @since 4.5.0
* @param array $controllers List of $namespace => $controllers to load.
*/
- return apply_filters(
- 'woocommerce_rest_api_get_rest_namespaces',
- array(
- 'wc/v1' => wc_rest_should_load_namespace( 'wc/v1' ) ? $this->get_v1_controllers() : array(),
- 'wc/v2' => wc_rest_should_load_namespace( 'wc/v2' ) ? $this->get_v2_controllers() : array(),
- 'wc/v3' => wc_rest_should_load_namespace( 'wc/v3' ) ? $this->get_v3_controllers() : array(),
- 'wc/v4' => ( wc_rest_should_load_namespace( 'wc/v4' ) && ( class_exists( \Automattic\WooCommerce\Admin\Features\Features::class ) && Features::is_enabled( 'rest-api-v4' ) ) ) ? $this->get_v4_controllers() : array(),
- 'wc-telemetry' => wc_rest_should_load_namespace( 'wc-telemetry' ) ? $this->get_telemetry_controllers() : array(),
- )
- );
+ return apply_filters( 'woocommerce_rest_api_get_rest_namespaces', $namespaces );
}
/**
@@ -210,9 +216,24 @@ class Server {
'ping' => 'WC_REST_Ping_V4_Controller',
'fulfillments' => 'WC_REST_Fulfillments_V4_Controller',
'products' => 'WC_REST_Products_V4_Controller',
+ 'order-notes' => OrderNotesController::class,
);
}
+ /**
+ * Get instance of a V4 controller.
+ *
+ * @param string $identifier Controller identifier.
+ * @param string $route Route class name.
+ * @return object The instance of the controller.
+ */
+ protected function get_v4_controller( $identifier, $route ) {
+ if ( isset( $this->controllers['wc/v4'][ $identifier ] ) ) {
+ return $this->controllers['wc/v4'][ $identifier ];
+ }
+ return new $route();
+ }
+
/**
* List of controllers in the telemetry namespace.
*
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php
index 4bd24bc718..4cfbfc673f 100644
--- a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php
@@ -13,7 +13,10 @@ namespace Automattic\WooCommerce\RestApi\Routes\V4;
use WP_Error;
use WP_Http;
+use WP_REST_Server;
use WP_REST_Controller;
+use WP_REST_Response;
+use WP_REST_Request;
defined( 'ABSPATH' ) || exit;
@@ -47,6 +50,44 @@ abstract class AbstractController extends WP_REST_Controller {
*/
protected $schema;
+ /**
+ * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
+ * after running various hooks on the response.
+ *
+ * This should return the full schema object, not just the properties.
+ *
+ * @return array The full item schema.
+ */
+ abstract protected function get_schema(): array;
+
+ /**
+ * Get the collection args schema.
+ *
+ * @return array
+ */
+ protected function get_query_schema(): array {
+ return array();
+ }
+
+ /**
+ * Add default context collection params and filter the result. This does not inherit from
+ * WP_REST_Controller::get_collection_params because some endpoints do not paginate results.
+ *
+ * @return array
+ */
+ public function get_collection_params() {
+ $params = $this->get_query_schema();
+ $params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
+
+ /**
+ * Filter the collection params.
+ *
+ * @param array $params The collection params.
+ * @since 10.2.0
+ */
+ return apply_filters( $this->get_hook_prefix() . 'collection_params', $params, $this );
+ }
+
/**
* Get item schema, conforming to JSON Schema. Extended by routes.
*
@@ -54,24 +95,68 @@ abstract class AbstractController extends WP_REST_Controller {
* @since 10.2.0
*/
public function get_item_schema() {
+ // Cache the schema for the route.
if ( null === $this->schema ) {
- $this->schema = array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'type' => 'object',
- 'title' => 'item',
- 'properties' => array(),
- );
/**
* Filter the item schema for this route.
*
* @param array $schema The item schema.
* @since 10.2.0
*/
- $this->schema = apply_filters( $this->get_hook_prefix() . 'item_schema', $this->add_additional_fields_schema( $this->schema ) );
+ $this->schema = apply_filters( $this->get_hook_prefix() . 'item_schema', $this->add_additional_fields_schema( $this->get_schema() ) );
}
return $this->schema;
}
+ /**
+ * Get the item response.
+ *
+ * @param mixed $item WooCommerce representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @return array The item response.
+ * @since 10.2.0
+ */
+ abstract protected function get_item_response( $item, WP_REST_Request $request ): array;
+
+ /**
+ * Prepare links for the request.
+ *
+ * @param mixed $item WordPress representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @return array
+ */
+ protected function prepare_links( $item, WP_REST_Request $request ): array {
+ return array();
+ }
+
+ /**
+ * Prepares the item for the REST response. Controllers do not need to override this method as they can define a
+ * get_item_response method to prepare items. This method will take care of filter hooks.
+ *
+ * @param mixed $item WordPress representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ * @since 10.2.0
+ */
+ public function prepare_item_for_response( $item, $request ) {
+ $response_data = $this->get_item_response( $item, $request );
+ $response_data = $this->add_additional_fields_to_object( $response_data, $request );
+ $response_data = $this->filter_response_by_context( $response_data, $request['context'] ?? 'view' );
+
+ $response = rest_ensure_response( $response_data );
+ $response->add_links( $this->prepare_links( $item, $request ) );
+
+ /**
+ * Filter the data for a response.
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param mixed $item WordPress representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @since 10.2.0
+ */
+ return rest_ensure_response( apply_filters( $this->get_hook_prefix() . 'item_response', $response, $item, $request ) );
+ }
+
/**
* Get the hook prefix for actions and filters.
*
@@ -81,7 +166,7 @@ abstract class AbstractController extends WP_REST_Controller {
* @since 10.2.0
*/
protected function get_hook_prefix(): string {
- return 'woocommerce_rest_api_v4_' . $this->rest_base . '_';
+ return 'woocommerce_rest_api_v4_' . str_replace( '-', '_', $this->rest_base ) . '_';
}
/**
@@ -93,7 +178,7 @@ abstract class AbstractController extends WP_REST_Controller {
* @since 10.2.0
*/
protected function get_error_prefix(): string {
- return 'woocommerce_rest_api_v4_' . $this->rest_base . '_';
+ return 'woocommerce_rest_api_v4_' . str_replace( '-', '_', $this->rest_base ) . '_';
}
/**
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php
index 8c882106b5..92d3a55599 100644
--- a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php
@@ -22,7 +22,6 @@ defined( 'ABSPATH' ) || exit;
* @since 10.2.0
*/
abstract class AbstractSchema {
-
/**
* The schema item identifier.
*
@@ -56,9 +55,9 @@ abstract class AbstractSchema {
public static function get_item_schema(): array {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => self::IDENTIFIER,
+ 'title' => static::IDENTIFIER,
'type' => 'object',
- 'properties' => self::get_item_schema_properties(),
+ 'properties' => static::get_item_schema_properties(),
);
}
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Controller.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Controller.php
new file mode 100644
index 0000000000..cf48f59f47
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Controller.php
@@ -0,0 +1,321 @@
+<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
+/**
+ * REST API Order Notes controller
+ *
+ * Handles route registration, permissions, CRUD operations, and schema definition.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\RestApi\Routes\V4\AbstractController;
+use WP_Http;
+use WP_Error;
+use WP_Comment;
+use WC_Order;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * OrdersNotes Controller.
+ */
+class Controller extends AbstractController {
+ /**
+ * Route base.
+ *
+ * @var string
+ */
+ protected $rest_base = 'order-notes';
+
+ /**
+ * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
+ * after running various hooks on the response.
+ */
+ protected function get_schema(): array {
+ return OrderNoteSchema::get_item_schema();
+ }
+
+ /**
+ * Get the collection args schema.
+ *
+ * @return array
+ */
+ protected function get_query_schema(): array {
+ return QueryUtils::get_query_schema();
+ }
+
+ /**
+ * Register the routes for orders.
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ 'args' => array(
+ 'order_id' => array(
+ 'description' => __( 'The order ID that notes belong to.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+ ),
+ )
+ );
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<id>[\d]+)',
+ array(
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
+ 'type' => 'integer',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Prepare links for the request.
+ *
+ * @param mixed $item WordPress representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @return array
+ */
+ protected function prepare_links( $item, WP_REST_Request $request ): array {
+ return array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, (int) $item->comment_ID ) ),
+ ),
+ 'collection' => array(
+ 'href' => add_query_arg(
+ array( 'order_id' => (int) $item->comment_post_ID ),
+ rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) )
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Prepare a single order note item for response.
+ *
+ * @param WP_Comment $note Note object.
+ * @param WP_REST_Request $request Request object.
+ * @return array
+ */
+ protected function get_item_response( $note, WP_REST_Request $request ): array {
+ return array(
+ 'id' => (int) $note->comment_ID,
+ 'order_id' => (int) $note->comment_post_ID,
+ 'author' => $note->comment_author,
+ 'date_created' => wc_rest_prepare_date_response( $note->comment_date ),
+ 'date_created_gmt' => wc_rest_prepare_date_response( $note->comment_date_gmt ),
+ 'note' => $note->comment_content,
+ 'is_customer_note' => (bool) get_comment_meta( $note->comment_ID, 'is_customer_note', true ),
+ );
+ }
+
+ /**
+ * Check if a given request has access to the order.
+ *
+ * @param WC_Order|boolean $order The order object.
+ * @return WP_Error|boolean
+ */
+ protected function order_permissions_check( $order ) {
+ if ( ! $order || ! $order instanceof WC_Order ) {
+ return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid order ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+ }
+
+ if ( ! $this->check_permissions( 'order', 'edit', (int) $order->get_id() ) ) {
+ return $this->get_route_error_response( $this->get_error_prefix() . 'cannot_edit', __( 'Sorry, you are not allowed to access notes for this order.', 'woocommerce' ), rest_authorization_required_code() );
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if a given request has access to read an item.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return WP_Error|boolean
+ */
+ public function get_item_permissions_check( $request ) {
+ return $this->order_permissions_check( Utils::get_order_by_note_id( (int) $request['id'] ) );
+ }
+
+ /**
+ * Check if a given request has access to read items.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|boolean
+ */
+ public function get_items_permissions_check( $request ) {
+ return $this->order_permissions_check( Utils::get_order_by_id( (int) $request['order_id'] ) );
+ }
+
+ /**
+ * Check if a given request has access to create an item.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return WP_Error|boolean
+ */
+ public function create_item_permissions_check( $request ) {
+ return $this->order_permissions_check( Utils::get_order_by_id( (int) $request['order_id'] ) );
+ }
+
+ /**
+ * Check if a given request has access to delete an item.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return bool|WP_Error
+ */
+ public function delete_item_permissions_check( $request ) {
+ return $this->order_permissions_check( Utils::get_order_by_note_id( (int) $request['id'] ) );
+ }
+
+ /**
+ * Get a single item.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response
+ */
+ public function get_item( $request ) {
+ $note = Utils::get_note_by_id( (int) $request['id'] );
+
+ if ( ! $note ) {
+ return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid resource ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+ }
+
+ return $this->prepare_item_for_response( $note, $request );
+ }
+
+ /**
+ * Get collection of orders.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response
+ */
+ public function get_items( $request ) {
+ $order = Utils::get_order_by_id( (int) $request['order_id'] );
+
+ if ( ! $order ) {
+ return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid order ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+ }
+
+ $results = QueryUtils::get_query_results( $request, $order );
+ $items = array();
+
+ foreach ( $results as $result ) {
+ $items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $result, $request ) );
+ }
+
+ return rest_ensure_response( $items );
+ }
+
+ /**
+ * Create a single item.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_Error|WP_REST_Response
+ */
+ public function create_item( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ /* translators: %s: post type */
+ return $this->get_route_error_response( $this->get_error_prefix() . 'exists', __( 'Cannot create existing order note.', 'woocommerce' ), WP_Http::BAD_REQUEST );
+ }
+
+ $order = Utils::get_order_by_id( (int) $request['order_id'] );
+ $note_id = $order ? $order->add_order_note( $request['note'], $request['is_customer_note'], true ) : null;
+
+ if ( ! $note_id ) {
+ return $this->get_route_error_response( $this->get_error_prefix() . 'cannot_create', __( 'Cannot create order note.', 'woocommerce' ), WP_Http::INTERNAL_SERVER_ERROR );
+ }
+
+ $note = get_comment( $note_id );
+ $this->update_additional_fields_for_object( $note, $request );
+
+ /**
+ * Fires after a single object is created via the REST API.
+ *
+ * @param WP_Comment $note Inserted object.
+ * @param WP_REST_Request $request Request object.
+ * @since 10.2.0
+ */
+ do_action( $this->get_hook_prefix() . 'created', $note, $request );
+
+ $request->set_param( 'context', 'edit' );
+ $response = $this->prepare_item_for_response( $note, $request );
+ $response->set_status( WP_Http::CREATED );
+ $response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $note_id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Delete a single item.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function delete_item( $request ) {
+ $note = Utils::get_note_by_id( (int) $request['id'] );
+
+ if ( empty( $note ) ) {
+ return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid resource ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+ }
+
+ $request->set_param( 'context', 'edit' );
+ $response = $this->prepare_item_for_response( $note, $request );
+
+ $result = wc_delete_order_note( (int) $note->comment_ID );
+
+ if ( ! $result ) {
+ return $this->get_route_error_response( $this->get_error_prefix() . 'cannot_delete', __( 'This object cannot be deleted.', 'woocommerce' ), WP_Http::INTERNAL_SERVER_ERROR );
+ }
+
+ /**
+ * Fires after a single object is deleted or trashed via the REST API.
+ *
+ * @param WP_Comment $note The deleted or trashed object.
+ * @param WP_REST_Response $response The response data.
+ * @param WP_REST_Request $request The request sent to the API.
+ * @since 10.2.0
+ */
+ do_action( $this->get_hook_prefix() . 'deleted', $note, $response, $request );
+
+ return $response;
+ }
+}
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/OrderNoteSchema.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/OrderNoteSchema.php
new file mode 100644
index 0000000000..68527e9fc9
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/OrderNoteSchema.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * OrderNoteSchema class.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\RestApi\Routes\V4\AbstractSchema;
+
+/**
+ * OrderNoteSchema class.
+ */
+class OrderNoteSchema extends AbstractSchema {
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'order_note';
+
+ /**
+ * Return all properties for the item schema.
+ *
+ * Note that context determines under which context data should be visible. For example, edit would be the context
+ * used when getting records with the intent of editing them. embed context allows the data to be visible when the
+ * item is being embedded in another response.
+ *
+ * @return array
+ */
+ public static function get_item_schema_properties(): array {
+ $schema = array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'order_id' => array(
+ 'description' => __( 'Order ID the note belongs to.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => self::VIEW_EDIT_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'author' => array(
+ 'description' => __( 'Order note author.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'date_created' => array(
+ 'description' => __( "The date the order note was created, in the site's timezone.", 'woocommerce' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'date_created_gmt' => array(
+ 'description' => __( 'The date the order note was created, as GMT.', 'woocommerce' ),
+ 'type' => 'string',
+ 'format' => 'date-time',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'note' => array(
+ 'description' => __( 'Order note content.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'required' => true,
+ ),
+ 'is_customer_note' => array(
+ 'description' => __( 'If true, the note will be shown to customers. If false, the note will be for admin reference only.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ ),
+ );
+
+ return $schema;
+ }
+}
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/QueryUtils.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/QueryUtils.php
new file mode 100644
index 0000000000..3d5f3470f0
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/QueryUtils.php
@@ -0,0 +1,80 @@
+<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
+/**
+ * QueryUtils class.
+ *
+ * @package WooCommerce\RestApi
+ * @internal This file is for internal use only and should not be used by external code.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use WP_REST_Request;
+use WC_Order;
+
+/**
+ * QueryUtils class.
+ *
+ * @internal This class is for internal use only and should not be used by external code.
+ */
+final class QueryUtils {
+ /**
+ * Get query schema.
+ *
+ * @return array
+ */
+ public static function get_query_schema() {
+ return array(
+ 'note_type' => array(
+ 'default' => 'all',
+ 'description' => __( 'Limit result to customer notes or private notes.', 'woocommerce' ),
+ 'type' => 'string',
+ 'enum' => array( 'all', 'customer', 'private' ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ );
+ }
+
+ /**
+ * Get results of the query.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @param WC_Order $order The order object.
+ * @return array
+ */
+ public static function get_query_results( WP_REST_Request $request, WC_Order $order ): array {
+ $args = array(
+ 'post_id' => $order->get_id(),
+ 'status' => 'approve',
+ 'type' => 'order_note',
+ );
+
+ // Allow filter by order note type.
+ if ( 'customer' === $request['note_type'] ) {
+ $args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ array(
+ 'key' => 'is_customer_note',
+ 'value' => 1,
+ 'compare' => '=',
+ ),
+ );
+ } elseif ( 'private' === $request['note_type'] ) {
+ $args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ array(
+ 'key' => 'is_customer_note',
+ 'compare' => 'NOT EXISTS',
+ ),
+ );
+ }
+
+ remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
+ $results = get_comments( $args );
+ add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
+
+ return (array) $results;
+ }
+}
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Utils.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Utils.php
new file mode 100644
index 0000000000..0ad79747af
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Utils.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Helpers for order notes.
+ *
+ * @package WooCommerce\RestApi
+ * @internal This file is for internal use only and should not be used by external code.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use WP_Comment;
+use WC_Order;
+
+/**
+ * Utils class.
+ *
+ * @internal This class is for internal use only and should not be used by external code.
+ */
+final class Utils {
+ /**
+ * Get an order by ID.
+ *
+ * @param int $order_id The order ID.
+ * @return WC_Order|null
+ */
+ public static function get_order_by_id( int $order_id ) {
+ if ( ! $order_id ) {
+ return null;
+ }
+ $order = wc_get_order( $order_id );
+ return $order && 'shop_order' === $order->get_type() ? $order : null;
+ }
+ /**
+ * Get the parent order of a note.
+ *
+ * @param int|WP_Comment $note The note ID or note object.
+ * @return WC_Order|null
+ */
+ public static function get_order_by_note_id( $note ) {
+ $note = $note instanceof WP_Comment ? $note : self::get_note_by_id( (int) $note );
+ if ( ! $note ) {
+ return null;
+ }
+ return self::get_order_by_id( (int) $note->comment_post_ID );
+ }
+
+ /**
+ * Get a note by ID.
+ *
+ * @param int $note_id The note ID.
+ * @return WP_Comment|null
+ */
+ public static function get_note_by_id( int $note_id ) {
+ if ( ! $note_id ) {
+ return null;
+ }
+ $note = get_comment( $note_id );
+ return $note && 'order_note' === $note->comment_type ? $note : null;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/OrderNotes/class-wc-rest-order-notes-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/OrderNotes/class-wc-rest-order-notes-v4-controller-tests.php
new file mode 100644
index 0000000000..f8bcf10457
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/OrderNotes/class-wc-rest-order-notes-v4-controller-tests.php
@@ -0,0 +1,383 @@
+<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+
+use Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes\Controller as OrderNotesController;
+
+/**
+ * class Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes\Controller tests.
+ * Order Notes Controller tests for V4 REST API.
+ */
+class WC_REST_Order_Notes_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
+ use HPOSToggleTrait;
+
+ /**
+ * Runs after each test.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ $this->disable_rest_api_v4_feature();
+ }
+
+ /**
+ * Enable the REST API v4 feature.
+ */
+ public static function enable_rest_api_v4_feature() {
+ add_filter(
+ 'woocommerce_admin_features',
+ function ( $features ) {
+ $features[] = 'rest-api-v4';
+ return $features;
+ },
+ );
+ }
+
+ /**
+ * Disable the REST API v4 feature.
+ */
+ public static function disable_rest_api_v4_feature() {
+ add_filter(
+ 'woocommerce_admin_features',
+ function ( $features ) {
+ $features = array_diff( $features, array( 'rest-api-v4' ) );
+ return $features;
+ }
+ );
+ }
+
+ /**
+ * Setup our test server, endpoints, and user info.
+ */
+ public function setUp(): void {
+ $this->enable_rest_api_v4_feature();
+ parent::setUp();
+ $this->endpoint = new OrderNotesController();
+ $this->user = $this->factory->user->create(
+ array(
+ 'role' => 'administrator',
+ )
+ );
+ wp_set_current_user( $this->user );
+ }
+
+ /**
+ * Test route registration.
+ */
+ public function test_register_routes() {
+ $routes = $this->server->get_routes();
+ $this->assertArrayHasKey( '/wc/v4/order-notes', $routes );
+ $this->assertArrayHasKey( '/wc/v4/order-notes/(?P<id>[\d]+)', $routes );
+ }
+
+ /**
+ * Test getting all order notes.
+ */
+ public function test_get_items() {
+ // Create an order.
+ $order = OrderHelper::create_order( $this->user );
+
+ // Add some order notes.
+ $order->add_order_note( 'Test note 1', false, false );
+ $order->add_order_note( 'Test note 2', true, false );
+ $order->add_order_note( 'Test note 3', false, false );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+ $request->set_query_params( array( 'order_id' => $order->get_id() ) );
+ $response = $this->server->dispatch( $request );
+ $notes = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertIsArray( $notes );
+ $this->assertGreaterThanOrEqual( 3, count( $notes ) );
+ }
+
+ /**
+ * Test getting order notes with order filter.
+ */
+ public function test_get_items_with_order_filter() {
+ // Create two orders.
+ $order1 = OrderHelper::create_order( $this->user );
+ $order2 = OrderHelper::create_order( $this->user );
+
+ // Add notes to both orders.
+ $order1->add_order_note( 'Order 1 note', false, false );
+ $order2->add_order_note( 'Order 2 note', false, false );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+ $request->set_query_params( array( 'order_id' => $order1->get_id() ) );
+ $response = $this->server->dispatch( $request );
+ $notes = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertIsArray( $notes );
+ $this->assertGreaterThanOrEqual( 1, count( $notes ) );
+
+ // All notes should belong to order 1.
+ foreach ( $notes as $note ) {
+ $this->assertEquals( $order1->get_id(), $note['order_id'] );
+ }
+ }
+
+ /**
+ * Test getting order notes with type filter.
+ */
+ public function test_get_items_with_type_filter() {
+ $order = OrderHelper::create_order( $this->user );
+
+ // Add different types of notes.
+ $order->add_order_note( 'Internal note', false, false );
+ $order->add_order_note( 'Customer note', true, false );
+
+ // Test internal notes filter.
+ $request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+ $request->set_query_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'note_type' => 'private',
+ )
+ );
+ $response = $this->server->dispatch( $request );
+ $notes = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertIsArray( $notes );
+
+ // All notes should be internal.
+ foreach ( $notes as $note ) {
+ $this->assertFalse( $note['is_customer_note'] );
+ }
+
+ // Test customer notes filter.
+ $request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+ $request->set_query_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'note_type' => 'customer',
+ )
+ );
+ $response = $this->server->dispatch( $request );
+ $notes = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertIsArray( $notes );
+
+ // All notes should be customer notes.
+ foreach ( $notes as $note ) {
+ $this->assertTrue( $note['is_customer_note'] );
+ }
+ }
+
+ /**
+ * Test creating an order note.
+ */
+ public function test_create_item() {
+ $order = OrderHelper::create_order( $this->user );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'note' => 'Test order note',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertArrayHasKey( 'id', $data );
+ $this->assertEquals( 'Test order note', $data['note'] );
+ $this->assertFalse( $data['is_customer_note'] );
+ $this->assertEquals( $order->get_id(), $data['order_id'] );
+ }
+
+ /**
+ * Test creating a customer order note.
+ */
+ public function test_create_is_customer_note() {
+ $order = OrderHelper::create_order( $this->user );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'note' => 'Customer order note',
+ 'is_customer_note' => true,
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertArrayHasKey( 'id', $data );
+ $this->assertEquals( 'Customer order note', $data['note'] );
+ $this->assertTrue( $data['is_customer_note'] );
+ $this->assertEquals( $order->get_id(), $data['order_id'] );
+ }
+
+ /**
+ * Test creating order note with invalid order ID.
+ */
+ public function test_create_item_invalid_order() {
+ $request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+ $request->set_body_params(
+ array(
+ 'order_id' => 99999,
+ 'note' => 'Test order note',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test creating order note without required fields.
+ */
+ public function test_create_item_missing_fields() {
+ $order = OrderHelper::create_order( $this->user );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ // Missing 'note' field.
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Test getting a single order note.
+ */
+ public function test_get_item() {
+ $order = OrderHelper::create_order( $this->user );
+ $note_id = $order->add_order_note( 'Test single note', false, false );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/order-notes/' . $note_id ) );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( $note_id, $data['id'] );
+ $this->assertEquals( 'Test single note', $data['note'] );
+ $this->assertFalse( $data['is_customer_note'] );
+ $this->assertEquals( $order->get_id(), $data['order_id'] );
+ }
+
+ /**
+ * Test getting non-existent order note.
+ */
+ public function test_get_item_not_found() {
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/order-notes/99999' ) );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test deleting an order note.
+ */
+ public function test_delete_item() {
+ $order = OrderHelper::create_order( $this->user );
+ $note_id = $order->add_order_note( 'Note to delete', false, false );
+
+ $request = new WP_REST_Request( 'DELETE', '/wc/v4/order-notes/' . $note_id );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ // Verify note is deleted.
+ $get_response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/order-notes/' . $note_id ) );
+ $this->assertEquals( 404, $get_response->get_status() );
+ }
+
+ /**
+ * Test deleting non-existent order note.
+ */
+ public function test_delete_item_not_found() {
+ $request = new WP_REST_Request( 'DELETE', '/wc/v4/order-notes/99999' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test order notes schema.
+ */
+ public function test_get_item_schema() {
+ $request = new WP_REST_Request( 'OPTIONS', '/wc/v4/order-notes' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'schema', $data );
+ $this->assertArrayHasKey( 'properties', $data['schema'] );
+
+ $properties = $data['schema']['properties'];
+ $this->assertArrayHasKey( 'id', $properties );
+ $this->assertArrayHasKey( 'order_id', $properties );
+ $this->assertArrayHasKey( 'note', $properties );
+ $this->assertArrayHasKey( 'is_customer_note', $properties );
+ $this->assertArrayHasKey( 'author', $properties );
+ $this->assertArrayHasKey( 'date_created', $properties );
+ $this->assertArrayHasKey( 'date_created_gmt', $properties );
+ }
+
+ /**
+ * Test order notes without permission.
+ */
+ public function test_get_items_without_permission() {
+ wp_set_current_user( 0 );
+ $order = OrderHelper::create_order( $this->user );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+ $request->set_query_params( array( 'order_id' => $order->get_id() ) );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 401, $response->get_status() );
+ }
+
+ /**
+ * Test creating order note without permission.
+ */
+ public function test_create_item_without_permission() {
+ wp_set_current_user( 0 );
+ $order = OrderHelper::create_order( $this->user );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'note' => 'Test order note',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 401, $response->get_status() );
+ }
+
+ /**
+ * Test deleting order note without permission.
+ */
+ public function test_delete_item_without_permission() {
+ wp_set_current_user( 0 );
+ $order = OrderHelper::create_order( $this->user );
+ $note_id = $order->add_order_note( 'Note to delete', false, false );
+
+ $request = new WP_REST_Request( 'DELETE', '/wc/v4/order-notes/' . $note_id );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 401, $response->get_status() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php
index 53b675e8cb..e5abea3374 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php
@@ -77,22 +77,18 @@ class Test_Abstract_Controller_V4 extends Automattic\WooCommerce\RestApi\Routes\
*
* @return array
*/
- public function get_item_schema(): array { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
- if ( null === $this->schema ) {
- $this->schema = array(
- '$schema' => 'http://json-schema.org/draft-04/schema#',
- 'type' => 'object',
- 'title' => Test_Abstract_Schema_V4::IDENTIFIER,
- 'properties' => Test_Abstract_Schema_V4::get_item_schema_properties(),
- );
- /**
- * Filter the item schema for this route.
- *
- * @param array $schema The item schema.
- * @since 10.2.0
- */
- $this->schema = apply_filters( $this->get_hook_prefix() . 'item_schema', $this->add_additional_fields_schema( $this->schema ) );
- }
- return $this->schema;
+ protected function get_schema(): array {
+ return Test_Abstract_Schema_V4::get_item_schema();
+ }
+
+ /**
+ * Get item response for testing.
+ *
+ * @param mixed $item WordPress representation of the item.
+ * @param WP_REST_Request $request Request object.
+ * @return array
+ */
+ protected function get_item_response( $item, WP_REST_Request $request ): array {
+ return array();
}
}