Repository: saleor/saleor
Pull request: Add mutations to update and remove order note
I want to merge this change because it resolves https://github.com/saleor/saleor/issues/12397.

- Added `OrderNoteAdd` and marked `OrderAddNote` mutation as deprecated. New mutation works the same as old one.
- Added `NOTE_UPDATED` option to `OrderEvents` enum.
- Added `OrderNoteUpdate` mutation.

How it works:

- The `OrderNoteUpdate` mutation accepts an `OrderEvent` id and an `input` with `message` key, validates that given `OrderEvent` is of `NOTE_ADDED` or `NOTE_UPDATED` type and creates new `OrderEvent` of type `NOTE_UPDATED` and sets FK `related` field to the original note id. It requires the `MANAGE_ORDERS` permission, same as the original `OrderAddNote` mutation.

<!-- Please mention all relevant issue numbers. -->

# Impact

* [x] New migrations
* [x] New/Updated API fields or mutations
* [x] Deprecated API fields or mutations
* [ ] Removed API types, fields, or mutations
* [ ] Documentation needs to be updated

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

* [x] Privileged queries and mutations are guarded by proper permission checks
* [x] Database queries are optimized and the number of queries is constant
* [x] Database migration files are up to date
* [x] The changes are tested
* [x] GraphQL schema and type definitions are up to date
* [x] Changes are mentioned in the changelog


Problem statement:
Introduce the way to edit and remove notes on orders
### What I'm trying to achieve
To maintain order and clarity of information in order notes,
As a staff member creating an order for the customer,
I need to be able to edit and remove notes from the order

### Describe a proposed solution
In case when the order is made by the shop's staff member (e.g.: who has a customer on the phone and is creating an order on the customer's behalf), it is very useful to add notes to the order (the notes are text-based, such as 'call this customer only after 12 o'clock.'). 

To add a note to an order in our project, we use the orderAddNote mutation, which adds an event to the order. However, the problem rises when the staff member made a spelling or copy/paste mistake, in this case, currently, we need to add another note explaining what's wrong in the previous one, which can easily create confusion. It would be much better if we could have the ability to edit note, or remove it entirely. 

The option to remove the note is also important when the staff member accidentally adds a note to the wrong order.

### Other solutions I've tried and won't work
I don't think there are any other solutions to this right now.

### Screenshots or mockups
<!-- Please provide any illustrations that could help others understand the problem or the proposed solution. -->

Hints from issue discussion:

Commit message: Update changelog

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77d9d3d6bdd..fb7b0158399 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Add `enable_account_confirmation_by_email` to `SiteSettings` model and allow to update it via `shopSettingsUpdate` mutation - #12781 by @SzymJ
 - [Preview] Add `OrderBulkCreate` mutation - #12269 by @zedzior
 - [Preview] Add `ORDER_BULK_CREATED` event, which is sent for successfully imported orders - #12536 by @zedzior
+- Add `orderNoteAdd` and `orderNoteUpdate` mutations and deprecate `orderAddNote` mutation - #12434 by @pawelzar
 
 ### Saleor Apps
 
diff --git a/saleor/graphql/order/dataloaders.py b/saleor/graphql/order/dataloaders.py
index 09eabf73d93..c977ebf1c28 100644
--- a/saleor/graphql/order/dataloaders.py
+++ b/saleor/graphql/order/dataloaders.py
@@ -115,6 +115,18 @@ def batch_load(self, keys):
         return [events_map.get(order_id, []) for order_id in keys]
 
 
+class OrderEventsByIdLoader(DataLoader):
+    context_key = "orderevents_by_id"
+
+    def batch_load(self, keys):
+        events = (
+            OrderEvent.objects.using(self.database_connection_name)
+            .filter(id__in=keys)
+            .in_bulk()
+        )
+        return [events.get(event_id) for event_id in keys]
+
+
 class OrderGrantedRefundsByOrderIdLoader(DataLoader):
     context_key = "order_granted_refunds_by_order_id"
 
diff --git a/saleor/graphql/order/mutations/order_add_note.py b/saleor/graphql/order/mutations/order_note_add.py
similarity index 69%
rename from saleor/graphql/order/mutations/order_add_note.py
rename to saleor/graphql/order/mutations/order_note_add.py
index 0dc4bd1b40f..d63f9b05b1b 100644
--- a/saleor/graphql/order/mutations/order_add_note.py
+++ b/saleor/graphql/order/mutations/order_note_add.py
@@ -1,65 +1,40 @@
 import graphene
-from django.core.exceptions import ValidationError
 
 from ....core.tracing import traced_atomic_transaction
 from ....order import events
-from ....order.error_codes import OrderErrorCode
 from ....permission.enums import OrderPermissions
 from ...app.dataloaders import get_app_promise
 from ...core import ResolveInfo
+from ...core.descriptions import (
+    ADDED_IN_314,
+    DEPRECATED_IN_3X_INPUT,
+    DEPRECATED_IN_3X_MUTATION,
+    PREVIEW_FEATURE,
+)
 from ...core.doc_category import DOC_CATEGORY_ORDERS
-from ...core.mutations import BaseMutation
 from ...core.types import BaseInputObjectType, OrderError
-from ...core.validators import validate_required_string_field
 from ...plugins.dataloaders import get_plugin_manager_promise
 from ..types import Order, OrderEvent
+from .order_note_common import OrderNoteCommon
 from .utils import get_webhook_handler_by_order_status
 
 
-class OrderAddNoteInput(BaseInputObjectType):
-    message = graphene.String(
-        description="Note message.", name="message", required=True
-    )
-
-    class Meta:
-        doc_category = DOC_CATEGORY_ORDERS
-
-
-class OrderAddNote(BaseMutation):
+class OrderNoteAdd(OrderNoteCommon):
     order = graphene.Field(Order, description="Order with the note added.")
     event = graphene.Field(OrderEvent, description="Order note created.")
 
-    class Arguments:
+    class Arguments(OrderNoteCommon.Arguments):
         id = graphene.ID(
             required=True,
             description="ID of the order to add a note for.",
             name="order",
         )
-        input = OrderAddNoteInput(
-            required=True, description="Fields required to create a note for the order."
-        )
 
     class Meta:
-        description = "Adds note to the order."
+        description = "Adds note to the order." + ADDED_IN_314 + PREVIEW_FEATURE
         doc_category = DOC_CATEGORY_ORDERS
         permissions = (OrderPermissions.MANAGE_ORDERS,)
         error_type_class = OrderError
-        error_type_field = "order_errors"
-
-    @classmethod
-    def clean_input(cls, _info, _instance, data):
-        try:
-            cleaned_input = validate_required_string_field(data, "message")
-        except ValidationError:
-            raise ValidationError(
-                {
-                    "message": ValidationError(
-                        "Message can't be empty.",
-                        code=OrderErrorCode.REQUIRED.value,
-                    )
-                }
-            )
-        return cleaned_input
 
     @classmethod
     def perform_mutation(  # type: ignore[override]
@@ -79,4 +54,29 @@ def perform_mutation(  # type: ignore[override]
             )
             func = get_webhook_handler_by_order_status(order.status, manager)
             cls.call_event(func, order)
-        return OrderAddNote(order=order, event=event)
+        return OrderNoteAdd(order=order, event=event)
+
+
+class OrderAddNoteInput(BaseInputObjectType):
+    message = graphene.String(
+        description="Note message." + DEPRECATED_IN_3X_INPUT,
+        name="message",
+        required=True,
+    )
+
+    class Meta:
+        doc_category = DOC_CATEGORY_ORDERS
+
+
+class OrderAddNote(OrderNoteAdd):
+    class Arguments(OrderNoteAdd.Arguments):
+        input = OrderAddNoteInput(
+            required=True, description="Fields required to create a note for the order."
+        )
+
+    class Meta:
+        description = "Adds note to the order." + DEPRECATED_IN_3X_MUTATION
+        doc_category = DOC_CATEGORY_ORDERS
+        permissions = (OrderPermissions.MANAGE_ORDERS,)
+        error_type_class = OrderError
+        error_type_field = "order_errors"
diff --git a/saleor/graphql/order/mutations/order_note_common.py b/saleor/graphql/order/mutations/order_note_common.py
new file mode 100644
index 00000000000..2d510ce1e21
--- /dev/null
+++ b/saleor/graphql/order/mutations/order_note_common.py
@@ -0,0 +1,42 @@
+import graphene
+from django.core.exceptions import ValidationError
+
+from ....order.error_codes import OrderErrorCode
+from ...core.doc_category import DOC_CATEGORY_ORDERS
+from ...core.mutations import BaseMutation
+from ...core.types import BaseInputObjectType
+from ...core.validators import validate_required_string_field
+
+
+class OrderNoteInput(BaseInputObjectType):
+    message = graphene.String(
+        description="Note message.", name="message", required=True
+    )
+
+    class Meta:
+        doc_category = DOC_CATEGORY_ORDERS
+
+
+class OrderNoteCommon(BaseMutation):
+    class Arguments:
+        input = OrderNoteInput(
+            required=True, description="Fields required to create a note for the order."
+        )
+
+    class Meta:
+        abstract = True
+
+    @classmethod
+    def clean_input(cls, _info, _instance, data):
+        try:
+            cleaned_input = validate_required_string_field(data, "message")
+        except ValidationError:
+            raise ValidationError(
+                {
+                    "message": ValidationError(
+                        "Message can't be empty.",
+                        code=OrderErrorCode.REQUIRED.value,
+                    )
+                }
+            )
+        return cleaned_input
diff --git a/saleor/graphql/order/mutations/order_note_update.py b/saleor/graphql/order/mutations/order_note_update.py
new file mode 100644
index 00000000000..542e262c427
--- /dev/null
+++ b/saleor/graphql/order/mutations/order_note_update.py
@@ -0,0 +1,58 @@
+import graphene
+
+from ....core.tracing import traced_atomic_transaction
+from ....order import OrderEvents, events, models
+from ....permission.enums import OrderPermissions
+from ...app.dataloaders import get_app_promise
+from ...core import ResolveInfo
+from ...core.descriptions import ADDED_IN_314, PREVIEW_FEATURE
+from ...core.doc_category import DOC_CATEGORY_ORDERS
+from ...core.types import OrderError
+from ...plugins.dataloaders import get_plugin_manager_promise
+from ..types import Order, OrderEvent
+from .order_note_common import OrderNoteCommon
+from .utils import get_webhook_handler_by_order_status
+
+
+class OrderNoteUpdate(OrderNoteCommon):
+    order = graphene.Field(Order, description="Order with the note updated.")
+    event = graphene.Field(OrderEvent, description="Order note updated.")
+
+    class Arguments(OrderNoteCommon.Arguments):
+        id = graphene.ID(
+            required=True,
+            description="ID of the note.",
+            name="note",
+        )
+
+    class Meta:
+        description = "Updates note of an order." + ADDED_IN_314 + PREVIEW_FEATURE
+        doc_category = DOC_CATEGORY_ORDERS
+        permissions = (OrderPermissions.MANAGE_ORDERS,)
+        error_type_class = OrderError
+
+    @classmethod
+    def perform_mutation(  # type: ignore[override]
+        cls, _root, info: ResolveInfo, /, *, id: str, input
+    ):
+        qs = models.OrderEvent.objects.filter(
+            type__in=[OrderEvents.NOTE_ADDED, OrderEvents.NOTE_UPDATED]
+        ).select_related("order")
+        order_event_to_update = cls.get_node_or_error(
+            info, id, only_type=OrderEvent, qs=qs
+        )
+        order = order_event_to_update.order
+        cleaned_input = cls.clean_input(info, order, input)
+        app = get_app_promise(info.context).get()
+        manager = get_plugin_manager_promise(info.context).get()
+        with traced_atomic_transaction():
+            event = events.order_note_updated_event(
+                order=order,
+                user=info.context.user,
+                app=app,
+                message=cleaned_input["message"],
+                related_event=order_event_to_update,
+            )
+            func = get_webhook_handler_by_order_status(order.status, manager)
+            cls.call_event(func, order)
+        return OrderNoteUpdate(order=order, event=event)
diff --git a/saleor/graphql/order/schema.py b/saleor/graphql/order/schema.py
index fb2efd0b49d..20a98044ec5 100644
--- a/saleor/graphql/order/schema.py
+++ b/saleor/graphql/order/schema.py
@@ -33,7 +33,6 @@
 from .mutations.fulfillment_refund_products import FulfillmentRefundProducts
 from .mutations.fulfillment_return_products import FulfillmentReturnProducts
 from .mutations.fulfillment_update_tracking import FulfillmentUpdateTracking
-from .mutations.order_add_note import OrderAddNote
 from .mutations.order_cancel import OrderCancel
 from .mutations.order_capture import OrderCapture
 from .mutations.order_confirm import OrderConfirm
@@ -49,6 +48,8 @@
 from .mutations.order_line_update import OrderLineUpdate
 from .mutations.order_lines_create import OrderLinesCreate
 from .mutations.order_mark_as_paid import OrderMarkAsPaid
+from .mutations.order_note_add import OrderAddNote, OrderNoteAdd
+from .mutations.order_note_update import OrderNoteUpdate
 from .mutations.order_refund import OrderRefund
 from .mutations.order_update import OrderUpdate
 from .mutations.order_update_shipping import OrderUpdateShipping
@@ -221,7 +222,9 @@ class OrderMutations(graphene.ObjectType):
     )
     draft_order_update = DraftOrderUpdate.Field()
 
-    order_add_note = OrderAddNote.Field()
+    order_add_note = OrderAddNote.Field(
+        deprecation_reason=(f"{DEPRECATED_IN_3X_FIELD} Use `orderNoteAdd` instead.")
+    )
     order_cancel = OrderCancel.Field()
     order_capture = OrderCapture.Field()
     order_confirm = OrderConfirm.Field()
@@ -247,6 +250,9 @@ class OrderMutations(graphene.ObjectType):
     order_line_discount_update = OrderLineDiscountUpdate.Field()
     order_line_discount_remove = OrderLineDiscountRemove.Field()
 
+    order_note_add = OrderNoteAdd.Field()
+    order_note_update = OrderNoteUpdate.Field()
+
     order_mark_as_paid = OrderMarkAsPaid.Field()
     order_refund = OrderRefund.Field()
     order_update = OrderUpdate.Field()
diff --git a/saleor/graphql/order/tests/mutations/test_order_add_note.py b/saleor/graphql/order/tests/mutations/test_order_note_add.py
similarity index 79%
rename from saleor/graphql/order/tests/mutations/test_order_add_note.py
rename to saleor/graphql/order/tests/mutations/test_order_note_add.py
index 529fb6912c0..e23c854307d 100644
--- a/saleor/graphql/order/tests/mutations/test_order_add_note.py
+++ b/saleor/graphql/order/tests/mutations/test_order_note_add.py
@@ -9,9 +9,9 @@
 from .....order.error_codes import OrderErrorCode
 from ....tests.utils import assert_no_permission, get_graphql_content
 
-ORDER_ADD_NOTE_MUTATION = """
+ORDER_NOTE_ADD_MUTATION = """
     mutation addNote($id: ID!, $message: String!) {
-        orderAddNote(order: $id, input: {message: $message}) {
+        orderNoteAdd(order: $id, input: {message: $message}) {
             errors {
                 field
                 message
@@ -35,7 +35,7 @@
 
 
 @patch("saleor.plugins.manager.PluginsManager.order_updated")
-def test_order_add_note_as_staff_user(
+def test_order_note_add_as_staff_user(
     order_updated_webhook_mock,
     staff_api_client,
     permission_group_manage_orders,
@@ -44,15 +44,20 @@ def test_order_add_note_as_staff_user(
 ):
     """We are testing that adding a note to an order as a staff user is doing the
     expected behaviors."""
+    # given
     permission_group_manage_orders.user_set.add(staff_api_client.user)
     order = order_with_lines
     assert not order.events.all()
     order_id = graphene.Node.to_global_id("Order", order.id)
     message = "nuclear note"
     variables = {"id": order_id, "message": message}
-    response = staff_api_client.post_graphql(ORDER_ADD_NOTE_MUTATION, variables)
+
+    # when
+    response = staff_api_client.post_graphql(ORDER_NOTE_ADD_MUTATION, variables)
+
+    # then
     content = get_graphql_content(response)
-    data = content["data"]["orderAddNote"]
+    data = content["data"]["orderNoteAdd"]
 
     assert data["order"]["id"] == order_id
     assert data["event"]["user"]["email"] == staff_user.email
@@ -68,7 +73,7 @@ def test_order_add_note_as_staff_user(
     assert event.user == staff_user
     assert event.parameters == {"message": message}
 
-    # Ensure not customer events were created as it was a staff action
+    # Ensure no customer events were created as it was a staff action
     assert not CustomerEvent.objects.exists()
 
 
@@ -80,19 +85,24 @@ def test_order_add_note_as_staff_user(
     ),
 )
 @patch("saleor.plugins.manager.PluginsManager.order_updated")
-def test_order_add_note_fail_on_empty_message(
+def test_order_note_add_fail_on_empty_message(
     order_updated_webhook_mock,
     staff_api_client,
     permission_group_manage_orders,
     order_with_lines,
     message,
 ):
+    # given
     permission_group_manage_orders.user_set.add(staff_api_client.user)
     order_id = graphene.Node.to_global_id("Order", order_with_lines.id)
     variables = {"id": order_id, "message": message}
-    response = staff_api_client.post_graphql(ORDER_ADD_NOTE_MUTATION, variables)
+
+    # when
+    response = staff_api_client.post_graphql(ORDER_NOTE_ADD_MUTATION, variables)
+
+    # then
     content = get_graphql_content(response)
-    data = content["data"]["orderAddNote"]
+    data = content["data"]["orderNoteAdd"]
     assert data["errors"][0]["field"] == "message"
     assert data["errors"][0]["code"] == OrderErrorCode.REQUIRED.name
     order_updated_webhook_mock.assert_not_called()
@@ -116,7 +126,7 @@ def test_order_add_note_as_user_no_channel_access(
     variables = {"id": order_id, "message": message}
 
     # when
-    response = staff_api_client.post_graphql(ORDER_ADD_NOTE_MUTATION, variables)
+    response = staff_api_client.post_graphql(ORDER_NOTE_ADD_MUTATION, variables)
 
     # then
     assert_no_permission(response)
@@ -138,15 +148,27 @@ def test_order_add_note_by_app(
 
     # when
     response = app_api_client.post_graphql(
-        ORDER_ADD_NOTE_MUTATION, variables, permissions=(permission_manage_orders,)
+        ORDER_NOTE_ADD_MUTATION, variables, permissions=(permission_manage_orders,)
     )
 
     # then
     content = get_graphql_content(response)
-    data = content["data"]["orderAddNote"]
+    data = content["data"]["orderNoteAdd"]
 
     assert data["order"]["id"] == order_id
     assert data["event"]["user"] is None
     assert data["event"]["app"]["name"] == app_api_client.app.name
     assert data["event"]["message"] == message
     order_updated_webhook_mock.assert_called_once_with(order)
+
+
+def test_order_note_add_fail_on_missing_permission(staff_api_client, order):
+    # given
+    order_id = graphene.Node.to_global_id("Order", order.id)
+    variables = {"id": order_id, "message": "a note"}
+
+    # when
+    response = staff_api_client.post_graphql(ORDER_NOTE_ADD_MUTATION, variables)
+
+    # then
+    assert_no_permission(response)
diff --git a/saleor/graphql/order/tests/mutations/test_order_note_update.py b/saleor/graphql/order/tests/mutations/test_order_note_update.py
new file mode 100644
index 00000000000..4bfddef1716
--- /dev/null
+++ b/saleor/graphql/order/tests/mutations/test_order_note_update.py
@@ -0,0 +1,204 @@
+from unittest.mock import patch
+
+import graphene
+import pytest
+
+from .....account.models import CustomerEvent
+from .....order import OrderEvents, OrderStatus
+from .....order.error_codes import OrderErrorCode
+from .....order.models import OrderEvent
+from ....tests.utils import assert_no_permission, get_graphql_content
+
+ORDER_NOTE_UPDATE_MUTATION = """
+    mutation updateNote($id: ID!, $message: String!) {
+        orderNoteUpdate(note: $id, input: {message: $message}) {
+            errors {
+                field
+                message
+                code
+            }
+            order {
+                id
+            }
+            event {
+                id
+                user {
+                    email
+                }
+                message
+                related {
+                    id
+                    message
+                }
+            }
+        }
+    }
+"""
+
+
+@patch("saleor.plugins.manager.PluginsManager.order_updated")
+def test_order_note_update_as_staff_user(
+    order_updated_webhook_mock,
+    staff_api_client,
+    permission_manage_orders,
+    order,
+    staff_user,
+):
+    # given
+    parameters = {"message": "a note"}
+    note = OrderEvent.objects.create(
+        order=order,
+        type=OrderEvents.NOTE_ADDED,
+        user=staff_user,
+        parameters=parameters,
+    )
+    note_id = graphene.Node.to_global_id("OrderEvent", note.pk)
+    order_id = graphene.Node.to_global_id("Order", order.id)
+
+    message = "nuclear note"
+    variables = {"id": note_id, "message": message}
+
+    # when
+    response = staff_api_client.post_graphql(
+        ORDER_NOTE_UPDATE_MUTATION, variables, permissions=[permission_manage_orders]
+    )
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["orderNoteUpdate"]
+
+    assert data["order"]["id"] == order_id
+    assert data["event"]["user"]["email"] == staff_user.email
+    assert data["event"]["message"] == message
+    assert data["event"]["related"]["id"] == note_id
+    order_updated_webhook_mock.assert_called_once_with(order)
+
+    order.refresh_from_db()
+    assert order.status == OrderStatus.UNFULFILLED
+
+    assert OrderEvent.objects.filter(order=order).count() == 2
+    new_note = OrderEvent.objects.filter(order=order).exclude(pk=note.pk).get()
+    assert new_note.type == OrderEvents.NOTE_UPDATED
+    assert new_note.user == staff_user
+    assert new_note.parameters == {"message": message}
+
+    # Ensure no customer events were created as it was a staff action
+    assert not CustomerEvent.objects.exists()
+
+
+@patch("saleor.plugins.manager.PluginsManager.order_updated")
+def test_order_note_update_as_app(
+    order_updated_webhook_mock,
+    app_api_client,
+    permission_manage_orders,
+    order,
+    app,
+):
+    # given
+    parameters = {"message": "a note"}
+    note = OrderEvent.objects.create(
+        order=order,
+        type=OrderEvents.NOTE_ADDED,
+        parameters=parameters,
+    )
+    note_id = graphene.Node.to_global_id("OrderEvent", note.pk)
+    order_id = graphene.Node.to_global_id("Order", order.id)
+
+    message = "nuclear note"
+    variables = {"id": note_id, "message": message}
+
+    # when
+    response = app_api_client.post_graphql(
+        ORDER_NOTE_UPDATE_MUTATION, variables, permissions=[permission_manage_orders]
+    )
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["orderNoteUpdate"]
+
+    assert data["order"]["id"] == order_id
+    assert data["event"]["user"] is None
+    assert data["event"]["related"]["id"] == note_id
+    order_updated_webhook_mock.assert_called_once_with(order)
+
+    new_note = OrderEvent.objects.filter(order=order).exclude(pk=note.pk).get()
+    assert new_note.type == OrderEvents.NOTE_UPDATED
+    assert new_note.app == app
+    assert not new_note.user
+
+
+@pytest.mark.parametrize(
+    "message",
+    (
+        "",
+        "   ",
+    ),
+)
+@patch("saleor.plugins.manager.PluginsManager.order_updated")
+def test_order_note_update_fail_on_empty_message(
+    order_updated_webhook_mock,
+    staff_api_client,
+    permission_manage_orders,
+    order,
+    message,
+):
+    # given
+    note = OrderEvent.objects.create(
+        order=order,
+        type=OrderEvents.NOTE_ADDED,
+    )
+    note_id = graphene.Node.to_global_id("OrderEvent", note.pk)
+    variables = {"id": note_id, "message": message}
+
+    # when
+    response = staff_api_client.post_graphql(
+        ORDER_NOTE_UPDATE_MUTATION, variables, permissions=[permission_manage_orders]
+    )
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["orderNoteUpdate"]
+    assert data["errors"][0]["field"] == "message"
+    assert data["errors"][0]["code"] == OrderErrorCode.REQUIRED.name
+    order_updated_webhook_mock.assert_not_called()
+
+
+@patch("saleor.plugins.manager.PluginsManager.order_updated")
+def test_order_note_update_fail_on_wrong_id(
+    order_updated_webhook_mock,
+    staff_api_client,
+    permission_manage_orders,
+    order,
+):
+    # given
+    note = OrderEvent.objects.create(
+        order=order,
+        type=OrderEvents.UPDATED_ADDRESS,  # add different event type than NOTE_ADDED
+    )
+    note_id = graphene.Node.to_global_id("OrderEvent", note.pk)
+    variables = {"id": note_id, "message": "test"}
+
+    # when
+    response = staff_api_client.post_graphql(
+        ORDER_NOTE_UPDATE_MUTATION, variables, permissions=[permission_manage_orders]
+    )
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["orderNoteUpdate"]
+    assert data["errors"][0]["field"] == "id"
+    assert data["errors"][0]["code"] == OrderErrorCode.NOT_FOUND.name
+    order_updated_webhook_mock.assert_not_called()
+
+
+def test_order_note_remove_fail_on_missing_permission(staff_api_client, order):
+    # given
+    note = OrderEvent.objects.create(order=order, type=OrderEvents.NOTE_ADDED)
+    note_id = graphene.Node.to_global_id("OrderEvent", note.pk)
+    variables = {"id": note_id, "message": "test"}
+
+    # when
+    response = staff_api_client.post_graphql(ORDER_NOTE_UPDATE_MUTATION, variables)
+
+    # then
+    assert_no_permission(response)
diff --git a/saleor/graphql/order/types.py b/saleor/graphql/order/types.py
index d3fdc567252..d9a8d5d5977 100644
--- a/saleor/graphql/order/types.py
+++ b/saleor/graphql/order/types.py
@@ -74,6 +74,7 @@
     ADDED_IN_310,
     ADDED_IN_311,
     ADDED_IN_313,
+    ADDED_IN_314,
     DEPRECATED_IN_3X_FIELD,
     PREVIEW_FEATURE,
     PREVIEW_FEATURE_DEPRECATED_IN_313_FIELD,
@@ -142,6 +143,7 @@
     FulfillmentsByOrderIdLoader,
     OrderByIdLoader,
     OrderByNumberLoader,
+    OrderEventsByIdLoader,
     OrderEventsByOrderIdLoader,
     OrderGrantedRefundsByOrderIdLoader,
     OrderLineByIdLoader,
@@ -340,6 +342,10 @@ class OrderEvent(ModelObjectType[models.OrderEvent]):
     related_order = graphene.Field(
         lambda: Order, description="The order which is related to this order."
     )
+    related = graphene.Field(
+        lambda: OrderEvent,
+        description="The order event which is related to this event." + ADDED_IN_314,
+    )
     discount = graphene.Field(
         OrderEventDiscountObject, description="The discount applied to the order."
     )
@@ -520,6 +526,12 @@ def resolve_related_order(root: models.OrderEvent, info):
 
         return OrderByIdLoader(info.context).load(order_pk)
 
+    @staticmethod
+    def resolve_related(root: models.OrderEvent, info):
+        if not root.related_id:
+            return None
+        return OrderEventsByIdLoader(info.context).load(root.related_id)
+
     @staticmethod
     def resolve_discount(root: models.OrderEvent, info):
         discount_obj = root.parameters.get("discount")
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 933018c6373..0fc456bae28 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -11076,6 +11076,13 @@ type OrderEvent implements Node @doc(category: "Orders") {
   """The order which is related to this order."""
   relatedOrder: Order
 
+  """
+  The order event which is related to this event.
+  
+  Added in Saleor 3.14.
+  """
+  related: OrderEvent
+
   """The discount applied to the order."""
   discount: OrderEventDiscountObject
 
@@ -11145,6 +11152,7 @@ enum OrderEventsEnum @doc(category: "Orders") {
   FULFILLMENT_AWAITS_APPROVAL
   TRACKING_UPDATED
   NOTE_ADDED
+  NOTE_UPDATED
   OTHER
 }
 
@@ -14653,7 +14661,9 @@ type Mutation {
   ): DraftOrderUpdate @doc(category: "Orders")
 
   """
-  Adds note to the order. 
+  Adds note to the order.
+  
+  DEPRECATED: this mutation will be removed in Saleor 4.0. 
   
   Requires one of the following permissions: MANAGE_ORDERS.
   """
@@ -14663,7 +14673,7 @@ type Mutation {
 
     """Fields required to create a note for the order."""
     input: OrderAddNoteInput!
-  ): OrderAddNote @doc(category: "Orders")
+  ): OrderAddNote @doc(category: "Orders") @deprecated(reason: "This field will be removed in Saleor 4.0. Use `orderNoteAdd` instead.")
 
   """
   Cancel an order. 
@@ -14910,6 +14920,40 @@ type Mutation {
     orderLineId: ID!
   ): OrderLineDiscountRemove @doc(category: "Orders")
 
+  """
+  Adds note to the order.
+  
+  Added in Saleor 3.14.
+  
+  Note: this API is currently in Feature Preview and can be subject to changes at later point. 
+  
+  Requires one of the following permissions: MANAGE_ORDERS.
+  """
+  orderNoteAdd(
+    """ID of the order to add a note for."""
+    order: ID!
+
+    """Fields required to create a note for the order."""
+    input: OrderNoteInput!
+  ): OrderNoteAdd @doc(category: "Orders")
+
+  """
+  Updates note of an order.
+  
+  Added in Saleor 3.14.
+  
+  Note: this API is currently in Feature Preview and can be subject to changes at later point. 
+  
+  Requires one of the following permissions: MANAGE_ORDERS.
+  """
+  orderNoteUpdate(
+    """ID of the note."""
+    note: ID!
+
+    """Fields required to create a note for the order."""
+    input: OrderNoteInput!
+  ): OrderNoteUpdate @doc(category: "Orders")
+
   """
   Mark order as manually paid. 
   
@@ -21808,7 +21852,9 @@ input DraftOrderInput @doc(category: "Orders") {
 }
 
 """
-Adds note to the order. 
+Adds note to the order.
+
+DEPRECATED: this mutation will be removed in Saleor 4.0. 
 
 Requires one of the following permissions: MANAGE_ORDERS.
 """
@@ -21823,7 +21869,11 @@ type OrderAddNote @doc(category: "Orders") {
 }
 
 input OrderAddNoteInput @doc(category: "Orders") {
-  """Note message."""
+  """
+  Note message.
+  
+  DEPRECATED: this field will be removed in Saleor 4.0.
+  """
   message: String!
 }
 
@@ -22307,6 +22357,47 @@ type OrderLineDiscountRemove @doc(category: "Orders") {
   errors: [OrderError!]!
 }
 
+"""
+Adds note to the order.
+
+Added in Saleor 3.14.
+
+Note: this API is currently in Feature Preview and can be subject to changes at later point. 
+
+Requires one of the following permissions: MANAGE_ORDERS.
+"""
+type OrderNoteAdd @doc(category: "Orders") {
+  """Order with the note added."""
+  order: Order
+
+  """Order note created."""
+  event: OrderEvent
+  errors: [OrderError!]!
+}
+
+input OrderNoteInput @doc(category: "Orders") {
+  """Note message."""
+  message: String!
+}
+
+"""
+Updates note of an order.
+
+Added in Saleor 3.14.
+
+Note: this API is currently in Feature Preview and can be subject to changes at later point. 
+
+Requires one of the following permissions: MANAGE_ORDERS.
+"""
+type OrderNoteUpdate @doc(category: "Orders") {
+  """Order with the note updated."""
+  order: Order
+
+  """Order note updated."""
+  event: OrderEvent
+  errors: [OrderError!]!
+}
+
 """
 Mark order as manually paid. 
 
diff --git a/saleor/order/__init__.py b/saleor/order/__init__.py
index ec8ca8f050d..dbe830beb5d 100644
--- a/saleor/order/__init__.py
+++ b/saleor/order/__init__.py
@@ -142,6 +142,7 @@ class OrderEvents:
     FULFILLMENT_AWAITS_APPROVAL = "fulfillment_awaits_approval"
     TRACKING_UPDATED = "tracking_updated"
     NOTE_ADDED = "note_added"
+    NOTE_UPDATED = "note_updated"
 
     # Used mostly for importing legacy data from before Enum-based events
     OTHER = "other"
@@ -199,6 +200,7 @@ class OrderEvents:
         (FULFILLMENT_AWAITS_APPROVAL, "Fulfillments awaits approval"),
         (TRACKING_UPDATED, "The fulfillment's tracking code was updated"),
         (NOTE_ADDED, "A note was added to the order"),
+        (NOTE_UPDATED, "A note was updated in the order"),
         (OTHER, "An unknown order event containing a message"),
     ]
 
diff --git a/saleor/order/events.py b/saleor/order/events.py
index de80e2c6e08..c97777ea5aa 100644
--- a/saleor/order/events.py
+++ b/saleor/order/events.py
@@ -705,6 +705,24 @@ def order_note_added_event(
     )
 
 
+def order_note_updated_event(
+    *,
+    order: Order,
+    user: Optional[User],
+    app: Optional[App],
+    message: str,
+    related_event: OrderEvent,
+) -> OrderEvent:
+    return OrderEvent.objects.create(
+        order=order,
+        type=OrderEvents.NOTE_UPDATED,
+        parameters={"message": message},
+        app=app,
+        user=user,
+        related=related_event,
+    )
+
+
 def _prepare_discount_object(
     order_discount: "OrderDiscount",
     old_order_discount: Optional["OrderDiscount"] = None,
diff --git a/saleor/order/migrations/0170_auto_20230529_1314.py b/saleor/order/migrations/0170_auto_20230529_1314.py
new file mode 100644
index 00000000000..063d02461d6
--- /dev/null
+++ b/saleor/order/migrations/0170_auto_20230529_1314.py
@@ -0,0 +1,90 @@
+# Generated by Django 3.2.19 on 2023-05-29 13:14
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("order", "0169_alter_order_options"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="orderevent",
+            name="related",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="related_events",
+                to="order.orderevent",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="orderevent",
+            name="type",
+            field=models.CharField(
+                choices=[
+                    ("DRAFT_CREATED", "draft_created"),
+                    ("DRAFT_CREATED_FROM_REPLACE", "draft_created_from_replace"),
+                    ("ADDED_PRODUCTS", "added_products"),
+                    ("REMOVED_PRODUCTS", "removed_products"),
+                    ("PLACED", "placed"),
+                    ("PLACED_FROM_DRAFT", "placed_from_draft"),
+                    ("OVERSOLD_ITEMS", "oversold_items"),
+                    ("CANCELED", "canceled"),
+                    ("EXPIRED", "expired"),
+                    ("ORDER_MARKED_AS_PAID", "order_marked_as_paid"),
+                    ("ORDER_FULLY_PAID", "order_fully_paid"),
+                    ("ORDER_REPLACEMENT_CREATED", "order_replacement_created"),
+                    ("ORDER_DISCOUNT_ADDED", "order_discount_added"),
+                    (
+                        "ORDER_DISCOUNT_AUTOMATICALLY_UPDATED",
+                        "order_discount_automatically_updated",
+                    ),
+                    ("ORDER_DISCOUNT_UPDATED", "order_discount_updated"),
+                    ("ORDER_DISCOUNT_DELETED", "order_discount_deleted"),
+                    ("ORDER_LINE_DISCOUNT_UPDATED", "order_line_discount_updated"),
+                    ("ORDER_LINE_DISCOUNT_REMOVED", "order_line_discount_removed"),
+                    ("ORDER_LINE_PRODUCT_DELETED", "order_line_product_deleted"),
+                    ("ORDER_LINE_VARIANT_DELETED", "order_line_variant_deleted"),
+                    ("UPDATED_ADDRESS", "updated_address"),
+                    ("EMAIL_SENT", "email_sent"),
+                    ("CONFIRMED", "confirmed"),
+                    ("PAYMENT_AUTHORIZED", "payment_authorized"),
+                    ("PAYMENT_CAPTURED", "payment_captured"),
+                    ("EXTERNAL_SERVICE_NOTIFICATION", "external_service_notification"),
+                    ("PAYMENT_REFUNDED", "payment_refunded"),
+                    ("PAYMENT_VOIDED", "payment_voided"),
+                    ("PAYMENT_FAILED", "payment_failed"),
+                    ("TRANSACTION_EVENT", "transaction_event"),
+                    ("TRANSACTION_CHARGE_REQUESTED", "transaction_charge_requested"),
+                    ("TRANSACTION_CAPTURE_REQUESTED", "transaction_capture_requested"),
+                    ("TRANSACTION_REFUND_REQUESTED", "transaction_refund_requested"),
+                    ("TRANSACTION_VOID_REQUESTED", "transaction_void_requested"),
+                    ("TRANSACTION_CANCEL_REQUESTED", "transaction_cancel_requested"),
+                    (
+                        "TRANSACTION_MARK_AS_PAID_FAILED",
+                        "transaction_mark_as_paid_failed",
+                    ),
+                    ("INVOICE_REQUESTED", "invoice_requested"),
+                    ("INVOICE_GENERATED", "invoice_generated"),
+                    ("INVOICE_UPDATED", "invoice_updated"),
+                    ("INVOICE_SENT", "invoice_sent"),
+                    ("FULFILLMENT_CANCELED", "fulfillment_canceled"),
+                    ("FULFILLMENT_RESTOCKED_ITEMS", "fulfillment_restocked_items"),
+                    ("FULFILLMENT_FULFILLED_ITEMS", "fulfillment_fulfilled_items"),
+                    ("FULFILLMENT_REFUNDED", "fulfillment_refunded"),
+                    ("FULFILLMENT_RETURNED", "fulfillment_returned"),
+                    ("FULFILLMENT_REPLACED", "fulfillment_replaced"),
+                    ("FULFILLMENT_AWAITS_APPROVAL", "fulfillment_awaits_approval"),
+                    ("TRACKING_UPDATED", "tracking_updated"),
+                    ("NOTE_ADDED", "note_added"),
+                    ("NOTE_UPDATED", "note_updated"),
+                    ("OTHER", "other"),
+                ],
+                max_length=255,
+            ),
+        ),
+    ]
diff --git a/saleor/order/models.py b/saleor/order/models.py
index b9037f3ebd3..a3c898ee9bf 100644
--- a/saleor/order/models.py
+++ b/saleor/order/models.py
@@ -803,6 +803,13 @@ class OrderEvent(models.Model):
         related_name="+",
     )
     app = models.ForeignKey(App, related_name="+", on_delete=models.SET_NULL, null=True)
+    related = models.ForeignKey(
+        "self",
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name="related_events",
+    )
 
     class Meta:
         ordering = ("date",)


Code review comments:
diff_hunk: @@ -0,0 +1,58 @@
+import graphene
+
+from ....core.tracing import traced_atomic_transaction
+from ....order import OrderEvents, events, models
+from ....permission.enums import OrderPermissions
+from ...app.dataloaders import get_app_promise
+from ...core import ResolveInfo
+from ...core.descriptions import ADDED_IN_314, PREVIEW_FEATURE
+from ...core.doc_category import DOC_CATEGORY_ORDERS
+from ...core.types import OrderError
+from ...plugins.dataloaders import get_plugin_manager_promise
+from ..types import Order, OrderEvent
+from .order_note_common import OrderNoteCommon
+from .utils import get_webhook_handler_by_order_status
+
+
+class OrderNoteUpdate(OrderNoteCommon):
+    order = graphene.Field(Order, description="Order with the note updated.")
+    event = graphene.Field(OrderEvent, description="Order note updated.")
+
+    class Arguments(OrderNoteCommon.Arguments):
+        id = graphene.ID(
+            required=True,
+            description="ID of the note.",
+            name="note",
+        )
+
+    class Meta:
+        description = "Updates note of an order." + ADDED_IN_314 + PREVIEW_FEATURE
+        doc_category = DOC_CATEGORY_ORDERS
+        permissions = (OrderPermissions.MANAGE_ORDERS,)
+        error_type_class = OrderError
path: saleor/graphql/order/mutations/order_note_update.py
text: @user1:
Same here. You should create a dedicated `error` type for the mutation.

diff_hunk: @@ -340,6 +342,10 @@ class OrderEvent(ModelObjectType[models.OrderEvent]):
     related_order = graphene.Field(
         lambda: Order, description="The order which is related to this order."
     )
+    related = graphene.Field(
+        lambda: OrderEvent,
+        description="The order event which is related to this event." + ADDED_IN_314,
path: saleor/graphql/order/types.py
text: @user1:
Shouldn't we also mark this field as a PREVIEW_FEATURE? The mutation that can set this field is in preview, so I think would be also consider adding PREVIEW_FEAUtRE here also

diff_hunk: @@ -0,0 +1,58 @@
+import graphene
+
+from ....core.tracing import traced_atomic_transaction
+from ....order import OrderEvents, events, models
+from ....permission.enums import OrderPermissions
+from ...app.dataloaders import get_app_promise
+from ...core import ResolveInfo
+from ...core.descriptions import ADDED_IN_314, PREVIEW_FEATURE
+from ...core.doc_category import DOC_CATEGORY_ORDERS
+from ...core.types import OrderError
+from ...plugins.dataloaders import get_plugin_manager_promise
+from ..types import Order, OrderEvent
+from .order_note_common import OrderNoteCommon
+from .utils import get_webhook_handler_by_order_status
+
+
+class OrderNoteUpdate(OrderNoteCommon):
+    order = graphene.Field(Order, description="Order with the note updated.")
+    event = graphene.Field(OrderEvent, description="Order note updated.")
+
+    class Arguments(OrderNoteCommon.Arguments):
+        id = graphene.ID(
+            required=True,
+            description="ID of the note.",
+            name="note",
+        )
+
+    class Meta:
+        description = "Updates note of an order." + ADDED_IN_314 + PREVIEW_FEATURE
+        doc_category = DOC_CATEGORY_ORDERS
+        permissions = (OrderPermissions.MANAGE_ORDERS,)
+        error_type_class = OrderError
+
+    @classmethod
+    def perform_mutation(  # type: ignore[override]
+        cls, _root, info: ResolveInfo, /, *, id: str, input
+    ):
+        qs = models.OrderEvent.objects.filter(
+            type__in=[OrderEvents.NOTE_ADDED, OrderEvents.NOTE_UPDATED]
+        ).select_related("order")
+        order_event_to_update = cls.get_node_or_error(
+            info, id, only_type=OrderEvent, qs=qs
+        )
+        order = order_event_to_update.order
+        cleaned_input = cls.clean_input(info, order, input)
+        app = get_app_promise(info.context).get()
+        manager = get_plugin_manager_promise(info.context).get()
+        with traced_atomic_transaction():
path: saleor/graphql/order/mutations/order_note_update.py
text: @user1:
I think we could use a normal transaction here instead of a traced transaction. 

@author:
Could you explain why? I'm using the `traced_atomic_transaction` just as it was in the original `OrderAddNote` mutation.

@user1:
I think tracing is not required for those operations. Some time ago we drop a lot of unnecessary tracing. Probably we miss tracing in `OrderAddNote` mutation.

diff_hunk: @@ -1,65 +1,40 @@
 import graphene
-from django.core.exceptions import ValidationError
 
 from ....core.tracing import traced_atomic_transaction
 from ....order import events
-from ....order.error_codes import OrderErrorCode
 from ....permission.enums import OrderPermissions
 from ...app.dataloaders import get_app_promise
 from ...core import ResolveInfo
+from ...core.descriptions import (
+    ADDED_IN_314,
+    DEPRECATED_IN_3X_INPUT,
+    DEPRECATED_IN_3X_MUTATION,
+    PREVIEW_FEATURE,
+)
 from ...core.doc_category import DOC_CATEGORY_ORDERS
-from ...core.mutations import BaseMutation
 from ...core.types import BaseInputObjectType, OrderError
-from ...core.validators import validate_required_string_field
 from ...plugins.dataloaders import get_plugin_manager_promise
 from ..types import Order, OrderEvent
+from .order_note_common import OrderNoteCommon
 from .utils import get_webhook_handler_by_order_status
 
 
-class OrderAddNoteInput(BaseInputObjectType):
-    message = graphene.String(
-        description="Note message.", name="message", required=True
-    )
-
-    class Meta:
-        doc_category = DOC_CATEGORY_ORDERS
-
-
-class OrderAddNote(BaseMutation):
+class OrderNoteAdd(OrderNoteCommon):
     order = graphene.Field(Order, description="Order with the note added.")
     event = graphene.Field(OrderEvent, description="Order note created.")
 
-    class Arguments:
+    class Arguments(OrderNoteCommon.Arguments):
         id = graphene.ID(
             required=True,
             description="ID of the order to add a note for.",
             name="order",
         )
-        input = OrderAddNoteInput(
-            required=True, description="Fields required to create a note for the order."
-        )
 
     class Meta:
-        description = "Adds note to the order."
+        description = "Adds note to the order." + ADDED_IN_314 + PREVIEW_FEATURE
path: saleor/graphql/order/mutations/order_note_add.py
text: @user1:
I think the scope of 3.14 is closed. We could release it in 3.15. 
```suggestion
        description = "Adds note to the order." + ADDED_IN_315 + PREVIEW_FEATURE
```

@user2:
@user1 It's not, we're still waiting for some internal PRs so I would keep it for now.