From e2d5263ee9f18ef213f41bd386fe42afb9a389f7 Mon Sep 17 00:00:00 2001
From: Kassio Borges <kborges@gitlab.com>
Date: Wed, 26 Feb 2025 18:48:33 +0000
Subject: [PATCH] Add GraphQL for WorkItems::UserPreference

With the end goal of persisting user preferences on WorkItem lists, like
sorting, we created WorkItems::UserPreference model. Now we're adding
the basic read/write GraphQL actions for this model to be used by the
frontend.

Related to: https://gitlab.com/gitlab-org/gitlab/-/issues/501712

Changelog: added
---
 .../work_items/user_preference/update.rb      |  55 ++++++++
 .../work_items/user_preference_resolver.rb    |  37 +++++
 app/graphql/types/current_user_type.rb        |   6 +
 app/graphql/types/mutation_type.rb            |   1 +
 .../work_items/notes_filter_type_enum.rb      |   2 +-
 .../types/work_items/user_preference.rb       |  30 +++++
 app/models/project.rb                         |   4 +
 app/models/work_items/sorting_keys.rb         |   8 ++
 app/models/work_items/type.rb                 |   2 +-
 .../work_items/types/user_preference.rb       |  16 ---
 app/models/work_items/user_preference.rb      |  56 ++++++++
 .../work_items/user_preference_policy.rb      |   7 +
 app/workers/all_queues.yml                    |  10 ++
 .../user_preferences/destroy_worker.rb        |  30 +++++
 config/sidekiq_queues.yml                     |   2 +
 db/docs/work_item_type_user_preferences.yml   |   2 +-
 doc/api/graphql/reference/_index.md           |  56 ++++++++
 ee/lib/ee/gitlab/event_store.rb               |   3 +-
 lib/gitlab/event_store.rb                     |  87 ++++++------
 locale/gitlab.pot                             |   6 +
 .../user_preference_resolver_spec.rb          |  88 ++++++++++++
 spec/models/project_spec.rb                   |  15 ++-
 spec/models/work_items/sorting_keys_spec.rb   |  30 +++++
 .../work_items/types/user_preference_spec.rb  |  11 --
 .../models/work_items/user_preference_spec.rb |  63 +++++++++
 .../work_items/user_preference_policy_spec.rb |  63 +++++++++
 .../work_items/user_preference/update_spec.rb | 126 ++++++++++++++++++
 .../user_preferences/destroy_worker_spec.rb   |  65 +++++++++
 28 files changed, 810 insertions(+), 71 deletions(-)
 create mode 100644 app/graphql/mutations/work_items/user_preference/update.rb
 create mode 100644 app/graphql/resolvers/work_items/user_preference_resolver.rb
 create mode 100644 app/graphql/types/work_items/user_preference.rb
 delete mode 100644 app/models/work_items/types/user_preference.rb
 create mode 100644 app/models/work_items/user_preference.rb
 create mode 100644 app/policies/work_items/user_preference_policy.rb
 create mode 100644 app/workers/work_items/user_preferences/destroy_worker.rb
 create mode 100644 spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb
 create mode 100644 spec/models/work_items/sorting_keys_spec.rb
 delete mode 100644 spec/models/work_items/types/user_preference_spec.rb
 create mode 100644 spec/models/work_items/user_preference_spec.rb
 create mode 100644 spec/policies/work_items/user_preference_policy_spec.rb
 create mode 100644 spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb
 create mode 100644 spec/workers/work_items/user_preferences/destroy_worker_spec.rb

diff --git a/app/graphql/mutations/work_items/user_preference/update.rb b/app/graphql/mutations/work_items/user_preference/update.rb
new file mode 100644
index 000000000000..b99fe16508ab
--- /dev/null
+++ b/app/graphql/mutations/work_items/user_preference/update.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Mutations
+  module WorkItems
+    module UserPreference
+      class Update < BaseMutation
+        graphql_name 'WorkItemUserPreferenceUpdate'
+        description "Create or Update user preferences for a work item type and namespace."
+
+        include Mutations::SpamProtection
+        include FindsNamespace
+
+        authorize :read_namespace
+
+        argument :namespace_path,
+          type: GraphQL::Types::ID,
+          required: true,
+          description: 'Full path of the namespace on which the preference is set.'
+        argument :work_item_type_id,
+          type: ::Types::GlobalIDType[::WorkItems::Type],
+          required: false,
+          description: 'Global ID of a work item type.'
+
+        argument :sort,
+          type: ::Types::WorkItems::SortEnum,
+          description: 'Sort order for work item lists.',
+          required: false,
+          default_value: :created_asc
+
+        field :user_preferences,
+          type: ::Types::WorkItems::UserPreference,
+          description: 'User preferences.'
+
+        def resolve(namespace_path:, work_item_type_id: nil, **attributes)
+          namespace = find_object(namespace_path)
+          namespace = namespace.project_namespace if namespace.is_a?(Project)
+          authorize!(namespace)
+
+          work_item_type_id = work_item_type_id&.model_id
+
+          preferences = ::WorkItems::UserPreference.create_or_update(
+            namespace: namespace,
+            work_item_type_id: work_item_type_id,
+            user: current_user,
+            **attributes)
+
+          {
+            user_preferences: preferences.valid? ? preferences : nil,
+            errors: errors_on_object(preferences)
+          }
+        end
+      end
+    end
+  end
+end
diff --git a/app/graphql/resolvers/work_items/user_preference_resolver.rb b/app/graphql/resolvers/work_items/user_preference_resolver.rb
new file mode 100644
index 000000000000..e7babf179bfd
--- /dev/null
+++ b/app/graphql/resolvers/work_items/user_preference_resolver.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Resolvers
+  module WorkItems
+    class UserPreferenceResolver < BaseResolver
+      include Gitlab::Graphql::Authorize::AuthorizeResource
+
+      type ::Types::WorkItems::UserPreference, null: true
+
+      authorize :read_namespace
+
+      argument :namespace_path,
+        GraphQL::Types::ID,
+        required: true,
+        description: 'Full path of the namespace the work item is created in.'
+
+      argument :work_item_type_id,
+        ::Types::GlobalIDType[::WorkItems::Type],
+        required: false,
+        description: 'Global ID of a work item type.'
+
+      def resolve(namespace_path:, work_item_type_id: nil)
+        namespace = ::Routable.find_by_full_path(namespace_path)
+        namespace = namespace.project_namespace if namespace.is_a?(Project)
+        authorize!(namespace)
+
+        work_item_type_id = work_item_type_id&.model_id
+
+        ::WorkItems::UserPreference.find_by_user_namespace_and_work_item_type_id(
+          current_user,
+          namespace,
+          work_item_type_id
+        )
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/current_user_type.rb b/app/graphql/types/current_user_type.rb
index 3ade06cd02fc..21fd786d7c77 100644
--- a/app/graphql/types/current_user_type.rb
+++ b/app/graphql/types/current_user_type.rb
@@ -22,6 +22,12 @@ class CurrentUserType < ::Types::UserType
       resolver: Resolvers::WorkItems::UserWorkItemsResolver,
       description: 'Find work items visible to the current user.',
       experiment: { milestone: '17.10' }
+
+    field :work_item_preferences, # rubocop:disable GraphQL/ExtractType -- fields with different contexts
+      resolver: ::Resolvers::WorkItems::UserPreferenceResolver,
+      null: true,
+      experiment: { milestone: '17.10' },
+      description: 'User preferences for the given work item type and namespace.'
   end
   # rubocop:enable Graphql/AuthorizeTypes
 end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 43467248b8db..b341e811a2eb 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -235,6 +235,7 @@ class MutationType < BaseObject
     mount_mutation Mutations::WorkItems::AddClosingMergeRequest, experiment: { milestone: '17.1' }
     mount_mutation Mutations::WorkItems::Hierarchy::Reorder, experiment: { milestone: '17.3' }
     mount_mutation Mutations::WorkItems::BulkUpdate, experiment: { milestone: '17.4' }
+    mount_mutation Mutations::WorkItems::UserPreference::Update, experiment: { milestone: '17.10' }
     mount_mutation Mutations::Users::SavedReplies::Create
     mount_mutation Mutations::Users::SavedReplies::Update
     mount_mutation Mutations::Users::SavedReplies::Destroy
diff --git a/app/graphql/types/work_items/notes_filter_type_enum.rb b/app/graphql/types/work_items/notes_filter_type_enum.rb
index 93fb4689f0b2..f507dbbe1393 100644
--- a/app/graphql/types/work_items/notes_filter_type_enum.rb
+++ b/app/graphql/types/work_items/notes_filter_type_enum.rb
@@ -9,7 +9,7 @@ class NotesFilterTypeEnum < BaseEnum
       ::UserPreference::NOTES_FILTERS.each_pair do |key, value|
         value key.upcase,
           value: value,
-          description: UserPreference.notes_filters.invert[::UserPreference::NOTES_FILTERS[key]]
+          description: ::UserPreference.notes_filters.invert[::UserPreference::NOTES_FILTERS[key]]
       end
 
       def self.default_value
diff --git a/app/graphql/types/work_items/user_preference.rb b/app/graphql/types/work_items/user_preference.rb
new file mode 100644
index 000000000000..ec376fc66d0a
--- /dev/null
+++ b/app/graphql/types/work_items/user_preference.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Types
+  module WorkItems
+    class UserPreference < BaseObject
+      graphql_name 'WorkItemTypesUserPreference'
+
+      authorize :read_namespace
+
+      field :namespace,
+        type: ::Types::NamespaceType,
+        null: false,
+        description: 'Namespace for the user preference.'
+
+      field :work_item_type,
+        type: ::Types::WorkItems::TypeType,
+        null: true,
+        description: 'Type assigned to the work item.'
+
+      field :sort,
+        type: ::Types::WorkItems::SortEnum,
+        null: true,
+        description: 'Sort order for work item lists.'
+
+      def sort
+        object.sort&.to_sym
+      end
+    end
+  end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 9548a04dd7e0..592c2f9a956d 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -1179,6 +1179,10 @@ def by_pages_enabled_unique_domain(domain)
           pages_unique_domain: domain
         })
     end
+
+    def project_namespace_for(id:)
+      find_by(id: id)&.project_namespace
+    end
   end
 
   def initialize(attributes = nil)
diff --git a/app/models/work_items/sorting_keys.rb b/app/models/work_items/sorting_keys.rb
index 4f5ceb58f277..7c177f0d68c3 100644
--- a/app/models/work_items/sorting_keys.rb
+++ b/app/models/work_items/sorting_keys.rb
@@ -80,6 +80,14 @@ def widgets_sorting_keys
           .reduce({}, :merge)
       end
       strong_memoize_attr :widgets_sorting_keys
+
+      def available?(key, widget_list: [])
+        key = key.to_sym
+        widget_list = Array.wrap(widget_list)
+
+        DEFAULT_SORTING_KEYS.key?(key) ||
+          widget_list.any? { |widget| widget.sorting_keys.key?(key) }
+      end
     end
   end
 end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index 8a9aa3075965..8e642cb25013 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -76,7 +76,7 @@ class Type < ApplicationRecord
       through: :parent_restrictions, class_name: 'WorkItems::Type',
       foreign_key: :parent_type_id, source: :parent_type
     has_many :user_preferences,
-      class_name: 'WorkItems::Types::UserPreference',
+      class_name: 'WorkItems::UserPreference',
       inverse_of: :work_item_type
 
     before_validation :strip_whitespace
diff --git a/app/models/work_items/types/user_preference.rb b/app/models/work_items/types/user_preference.rb
deleted file mode 100644
index 3592e92cb7a5..000000000000
--- a/app/models/work_items/types/user_preference.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
-  module Types
-    class UserPreference < ApplicationRecord
-      self.table_name = 'work_item_type_user_preferences'
-
-      belongs_to :user
-      belongs_to :namespace
-      belongs_to :work_item_type,
-        class_name: 'WorkItems::Type',
-        inverse_of: :user_preferences,
-        optional: true
-    end
-  end
-end
diff --git a/app/models/work_items/user_preference.rb b/app/models/work_items/user_preference.rb
new file mode 100644
index 000000000000..6c158f8517ed
--- /dev/null
+++ b/app/models/work_items/user_preference.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module WorkItems
+  class UserPreference < ApplicationRecord
+    self.table_name = 'work_item_type_user_preferences'
+
+    belongs_to :user
+    belongs_to :namespace
+    belongs_to :work_item_type,
+      class_name: 'WorkItems::Type',
+      inverse_of: :user_preferences,
+      optional: true
+
+    validate :validate_sort_value
+
+    def self.create_or_update(namespace:, work_item_type_id:, user:, **attributes)
+      record = find_or_initialize_by(namespace: namespace, work_item_type_id: work_item_type_id, user: user)
+      record.assign_attributes(**attributes)
+      record.save
+      record
+    end
+
+    def self.find_by_user_namespace_and_work_item_type_id(user, namespace, work_item_type_id)
+      find_by(
+        user: user,
+        namespace: namespace,
+        work_item_type_id: work_item_type_id
+      )
+    end
+
+    private
+
+    def validate_sort_value
+      return if sort.blank?
+      return if ::WorkItems::SortingKeys.available?(sort, widget_list: work_item_type&.widget_classes(namespace))
+
+      message =
+        if work_item_type.present?
+          format(
+            _('value "%{sort}" is not available on %{namespace} for work items type %{wit}'),
+            sort: sort,
+            namespace: namespace.full_path,
+            wit: work_item_type.name
+          )
+        else
+          format(
+            _('value "%{sort}" is not available on %{namespace}'),
+            sort: sort,
+            namespace: namespace.full_path
+          )
+        end
+
+      errors.add(:sort, message)
+    end
+  end
+end
diff --git a/app/policies/work_items/user_preference_policy.rb b/app/policies/work_items/user_preference_policy.rb
new file mode 100644
index 000000000000..778455b053c3
--- /dev/null
+++ b/app/policies/work_items/user_preference_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module WorkItems
+  class UserPreferencePolicy < BasePolicy
+    delegate { @subject.namespace }
+  end
+end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index e5a124df37cc..f98e31efe9b2 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -4888,6 +4888,16 @@
   :idempotent: true
   :tags: []
   :queue_namespace:
+- :name: work_items_user_preferences_destroy
+  :worker_name: WorkItems::UserPreferences::DestroyWorker
+  :feature_category: :seat_cost_management
+  :has_external_dependencies: false
+  :urgency: :low
+  :resource_boundary: :unknown
+  :weight: 1
+  :idempotent: true
+  :tags: []
+  :queue_namespace:
 - :name: x509_certificate_revoke
   :worker_name: X509CertificateRevokeWorker
   :feature_category: :source_code_management
diff --git a/app/workers/work_items/user_preferences/destroy_worker.rb b/app/workers/work_items/user_preferences/destroy_worker.rb
new file mode 100644
index 000000000000..3367d9c49720
--- /dev/null
+++ b/app/workers/work_items/user_preferences/destroy_worker.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module WorkItems
+  module UserPreferences
+    class DestroyWorker
+      include Gitlab::EventStore::Subscriber
+
+      data_consistency :delayed
+      feature_category :seat_cost_management
+      urgency :low
+      idempotent!
+      deduplicate :until_executed
+
+      def handle_event(event)
+        case event.data[:source_type]
+        when GroupMember::SOURCE_TYPE
+          ::WorkItems::UserPreference.delete_by(
+            user_id: event.data[:user_id],
+            namespace_id: event.data[:source_id]
+          )
+        when ProjectMember::SOURCE_TYPE
+          ::WorkItems::UserPreference.delete_by(
+            user_id: event.data[:user_id],
+            namespace: Project.project_namespace_for(id: event.data[:source_id])
+          )
+        end
+      end
+    end
+  end
+end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index b82a4268d806..582d299ffc2a 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -1013,6 +1013,8 @@
   - 1
 - - work_items_update_parent_objectives_progress
   - 1
+- - work_items_user_preferences_destroy
+  - 1
 - - work_items_validate_epic_work_item_sync
   - 1
 - - x509_certificate_revoke
diff --git a/db/docs/work_item_type_user_preferences.yml b/db/docs/work_item_type_user_preferences.yml
index e20ad5637ac7..8c506c71b51f 100644
--- a/db/docs/work_item_type_user_preferences.yml
+++ b/db/docs/work_item_type_user_preferences.yml
@@ -1,7 +1,7 @@
 ---
 table_name: work_item_type_user_preferences
 classes:
-  - WorkItems::Types::UserPreference
+  - WorkItems::UserPreference
 feature_categories:
   - team_planning
 description: User preferences per work item type and namespace
diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index 0c7f12a06412..f6cbaeb1de32 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -12516,6 +12516,34 @@ Input type: `WorkItemUpdateInput`
 | <a id="mutationworkitemupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
 | <a id="mutationworkitemupdateworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated work item. |
 
+### `Mutation.workItemUserPreferenceUpdate`
+
+Create or Update user preferences for a work item type and namespace.
+
+{{< details >}}
+**Introduced** in GitLab 17.10.
+**Status**: Experiment.
+{{< /details >}}
+
+Input type: `WorkItemUserPreferenceUpdateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationworkitemuserpreferenceupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationworkitemuserpreferenceupdatenamespacepath"></a>`namespacePath` | [`ID!`](#id) | Full path of the namespace on which the preference is set. |
+| <a id="mutationworkitemuserpreferenceupdatesort"></a>`sort` | [`WorkItemSort`](#workitemsort) | Sort order for work item lists. |
+| <a id="mutationworkitemuserpreferenceupdateworkitemtypeid"></a>`workItemTypeId` | [`WorkItemsTypeID`](#workitemstypeid) | Global ID of a work item type. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationworkitemuserpreferenceupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationworkitemuserpreferenceupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| <a id="mutationworkitemuserpreferenceupdateuserpreferences"></a>`userPreferences` | [`WorkItemTypesUserPreference`](#workitemtypesuserpreference) | User preferences. |
+
 ### `Mutation.workItemsHierarchyReorder`
 
 Reorder a work item in the hierarchy tree.
@@ -23695,6 +23723,24 @@ four standard [pagination arguments](#pagination-arguments):
 | ---- | ---- | ----------- |
 | <a id="currentuseruserachievementsincludehidden"></a>`includeHidden` | [`Boolean`](#boolean) | Indicates whether or not achievements hidden from the profile should be included in the result. |
 
+##### `CurrentUser.workItemPreferences`
+
+User preferences for the given work item type and namespace.
+
+{{< details >}}
+**Introduced** in GitLab 17.10.
+**Status**: Experiment.
+{{< /details >}}
+
+Returns [`WorkItemTypesUserPreference`](#workitemtypesuserpreference).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="currentuserworkitempreferencesnamespacepath"></a>`namespacePath` | [`ID!`](#id) | Full path of the namespace the work item is created in. |
+| <a id="currentuserworkitempreferencesworkitemtypeid"></a>`workItemTypeId` | [`WorkItemsTypeID`](#workitemstypeid) | Global ID of a work item type. |
+
 ##### `CurrentUser.workItems`
 
 Find work items visible to the current user.
@@ -39562,6 +39608,16 @@ Represents Depth limit reached for the allowed work item type.
 | <a id="workitemtypedepthlimitreachedbytypedepthlimitreached"></a>`depthLimitReached` | [`Boolean!`](#boolean) | Indicates if maximum allowed depth has been reached for the descendant type. |
 | <a id="workitemtypedepthlimitreachedbytypeworkitemtype"></a>`workItemType` | [`WorkItemType!`](#workitemtype) | Work item type. |
 
+### `WorkItemTypesUserPreference`
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="workitemtypesuserpreferencenamespace"></a>`namespace` | [`Namespace!`](#namespace) | Namespace for the user preference. |
+| <a id="workitemtypesuserpreferencesort"></a>`sort` | [`WorkItemSort`](#workitemsort) | Sort order for work item lists. |
+| <a id="workitemtypesuserpreferenceworkitemtype"></a>`workItemType` | [`WorkItemType`](#workitemtype) | Type assigned to the work item. |
+
 ### `WorkItemWidgetAssignees`
 
 Represents an assignees widget.
diff --git a/ee/lib/ee/gitlab/event_store.rb b/ee/lib/ee/gitlab/event_store.rb
index 5a97c4333374..558468e771c8 100644
--- a/ee/lib/ee/gitlab/event_store.rb
+++ b/ee/lib/ee/gitlab/event_store.rb
@@ -83,11 +83,12 @@ def configure!(store)
           subscribe_to_zoekt_events(store)
           subscribe_to_members_added_event(store)
           subscribe_to_users_activity_events(store)
-          subscribe_to_member_destroyed_events(store)
           subscribe_to_merge_events(store)
         end
 
+        override :subscribe_to_member_destroyed_events
         def subscribe_to_member_destroyed_events(store)
+          super
           store.subscribe ::GitlabSubscriptions::Members::DestroyedWorker, to: ::Members::DestroyedEvent
         end
 
diff --git a/lib/gitlab/event_store.rb b/lib/gitlab/event_store.rb
index cf0d7ddfbf95..75070c56a065 100644
--- a/lib/gitlab/event_store.rb
+++ b/lib/gitlab/event_store.rb
@@ -13,49 +13,58 @@ module EventStore
     InvalidEvent = Class.new(Error)
     InvalidSubscriber = Class.new(Error)
 
-    def self.publish(event)
-      instance.publish(event)
-    end
+    class << self
+      def publish(event)
+        instance.publish(event)
+      end
 
-    def self.publish_group(events)
-      instance.publish_group(events)
-    end
+      def publish_group(events)
+        instance.publish_group(events)
+      end
 
-    def self.instance
-      @instance ||= Store.new { |store| configure!(store) }
-    end
+      def instance
+        @instance ||= Store.new { |store| configure!(store) }
+      end
+
+      private
+
+      # Define all event subscriptions using:
+      #
+      #   store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent)
+      #
+      # It is possible to subscribe to a subset of events matching a condition:
+      #
+      #   store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent), if: ->(event) { event.data == :some_value }
+      #
+      def configure!(store)
+        ###
+        # Add subscriptions here:
+
+        store.subscribe ::MergeRequests::UpdateHeadPipelineWorker, to: ::Ci::PipelineCreatedEvent
+        store.subscribe ::Namespaces::UpdateRootStatisticsWorker, to: ::Projects::ProjectDeletedEvent
+        store.subscribe ::Ci::Runners::UpdateProjectRunnersOwnerWorker, to: ::Projects::ProjectDeletedEvent
+
+        store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DraftStateChangeEvent
+        store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DiscussionsResolvedEvent
+        store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::MergeableEvent
+        store.subscribe ::MergeRequests::CreateApprovalEventWorker, to: ::MergeRequests::ApprovedEvent
+        store.subscribe ::MergeRequests::CreateApprovalNoteWorker, to: ::MergeRequests::ApprovedEvent
+        store.subscribe ::MergeRequests::ResolveTodosAfterApprovalWorker, to: ::MergeRequests::ApprovedEvent
+        store.subscribe ::MergeRequests::ExecuteApprovalHooksWorker, to: ::MergeRequests::ApprovedEvent
+        store.subscribe ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker,
+          to: ::Packages::PackageCreatedEvent,
+          if: ->(event) { ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker.handles_event?(event) }
+        store.subscribe ::Ci::InitializePipelinesIidSequenceWorker, to: ::Projects::ProjectCreatedEvent
+        store.subscribe ::Pages::DeletePagesDeploymentWorker, to: ::Projects::ProjectArchivedEvent
+        store.subscribe ::Pages::ResetPagesDefaultDomainRedirectWorker, to: ::Pages::Domains::PagesDomainDeletedEvent
+
+        subscribe_to_member_destroyed_events(store)
+      end
 
-    # Define all event subscriptions using:
-    #
-    #   store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent)
-    #
-    # It is possible to subscribe to a subset of events matching a condition:
-    #
-    #   store.subscribe(DomainA::SomeWorker, to: DomainB::SomeEvent), if: ->(event) { event.data == :some_value }
-    #
-    def self.configure!(store)
-      ###
-      # Add subscriptions here:
-
-      store.subscribe ::MergeRequests::UpdateHeadPipelineWorker, to: ::Ci::PipelineCreatedEvent
-      store.subscribe ::Namespaces::UpdateRootStatisticsWorker, to: ::Projects::ProjectDeletedEvent
-      store.subscribe ::Ci::Runners::UpdateProjectRunnersOwnerWorker, to: ::Projects::ProjectDeletedEvent
-
-      store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DraftStateChangeEvent
-      store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::DiscussionsResolvedEvent
-      store.subscribe ::MergeRequests::ProcessAutoMergeFromEventWorker, to: ::MergeRequests::MergeableEvent
-      store.subscribe ::MergeRequests::CreateApprovalEventWorker, to: ::MergeRequests::ApprovedEvent
-      store.subscribe ::MergeRequests::CreateApprovalNoteWorker, to: ::MergeRequests::ApprovedEvent
-      store.subscribe ::MergeRequests::ResolveTodosAfterApprovalWorker, to: ::MergeRequests::ApprovedEvent
-      store.subscribe ::MergeRequests::ExecuteApprovalHooksWorker, to: ::MergeRequests::ApprovedEvent
-      store.subscribe ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker,
-        to: ::Packages::PackageCreatedEvent,
-        if: ->(event) { ::Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker.handles_event?(event) }
-      store.subscribe ::Ci::InitializePipelinesIidSequenceWorker, to: ::Projects::ProjectCreatedEvent
-      store.subscribe ::Pages::DeletePagesDeploymentWorker, to: ::Projects::ProjectArchivedEvent
-      store.subscribe ::Pages::ResetPagesDefaultDomainRedirectWorker, to: ::Pages::Domains::PagesDomainDeletedEvent
+      def subscribe_to_member_destroyed_events(store)
+        store.subscribe ::WorkItems::UserPreferences::DestroyWorker, to: ::Members::DestroyedEvent
+      end
     end
-    private_class_method :configure!
   end
 end
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a488e6ed87ce..46b6dd575489 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -70313,6 +70313,12 @@ msgstr ""
 msgid "v%{version} published %{timeAgo}"
 msgstr ""
 
+msgid "value \"%{sort}\" is not available on %{namespace}"
+msgstr ""
+
+msgid "value \"%{sort}\" is not available on %{namespace} for work items type %{wit}"
+msgstr ""
+
 msgid "value at %{data_pointer} should use only one of: %{requirements}"
 msgstr ""
 
diff --git a/spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb b/spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb
new file mode 100644
index 000000000000..80b08dd25870
--- /dev/null
+++ b/spec/graphql/resolvers/work_items/user_preference_resolver_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Resolvers::WorkItems::UserPreferenceResolver, feature_category: :team_planning do
+  include GraphqlHelpers
+
+  let_it_be(:current_user) { create(:user) }
+  let_it_be(:work_item_type) { WorkItems::Type.default_by_type(:issue) }
+
+  let(:args) do
+    {
+      namespace_path: namespace.full_path,
+      work_item_type_id: work_item_type&.to_gid
+    }
+  end
+
+  let(:result) do
+    resolve(
+      described_class,
+      obj: current_user,
+      args: args,
+      ctx: {
+        current_user: current_user
+      }
+    )
+  end
+
+  shared_examples 'resolve work items user preferences' do
+    context 'when user does not have access to the namespace' do
+      it 'does not update the user preference and return access error' do
+        expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do
+          result
+        end
+      end
+    end
+
+    context 'when user have access to the namespace' do
+      before_all do
+        namespace.add_guest(current_user)
+      end
+
+      context 'when a user preference is not found' do
+        let_it_be(:work_item_type) { nil }
+
+        it 'returns nil when the user preference is not found' do
+          expect(result).to be_blank
+        end
+      end
+
+      context 'when a user preference is found' do
+        it 'returns the user preference when it is found' do
+          expect(result).to eq(user_preference)
+        end
+      end
+    end
+  end
+
+  context 'when namespace is a group' do
+    let_it_be(:namespace) { create(:group, :private) }
+
+    let_it_be(:user_preference) do
+      create(
+        :work_item_user_preference,
+        namespace: namespace,
+        work_item_type: work_item_type,
+        user: current_user
+      )
+    end
+
+    it_behaves_like 'resolve work items user preferences'
+  end
+
+  context 'when namespace is a project' do
+    let_it_be(:namespace) { create(:project, :private) }
+
+    let_it_be(:user_preference) do
+      create(
+        :work_item_user_preference,
+        namespace: namespace.project_namespace,
+        work_item_type: work_item_type,
+        user: current_user
+      )
+    end
+
+    it_behaves_like 'resolve work items user preferences'
+  end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 06d4895246ea..52a43daf0a53 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -413,7 +413,7 @@
       end
     end
 
-    context 'updating cd_cd_settings' do
+    context 'updating ci_cd_settings' do
       it 'does not raise an error' do
         project = create(:project)
 
@@ -421,6 +421,19 @@
       end
     end
 
+    describe '.project_namespace_for' do
+      it 'returns the project namespace for the given id' do
+        project = create(:project)
+        expect(described_class.project_namespace_for(id: project.id)).to eq(project.project_namespace)
+      end
+
+      context 'when project is not found' do
+        it 'returns nil' do
+          expect(described_class.project_namespace_for(id: non_existing_record_id)).to be_nil
+        end
+      end
+    end
+
     describe '#namespace_members' do
       let_it_be(:project) { create(:project, :public) }
       let_it_be(:requester) { create(:user) }
diff --git a/spec/models/work_items/sorting_keys_spec.rb b/spec/models/work_items/sorting_keys_spec.rb
new file mode 100644
index 000000000000..10776fd6b4dc
--- /dev/null
+++ b/spec/models/work_items/sorting_keys_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe WorkItems::SortingKeys, feature_category: :team_planning do
+  describe '#available?' do
+    context 'when no widget list is given' do
+      it 'returns true when passing a default sorting key' do
+        expect(described_class.available?('title_desc')).to be(true)
+      end
+
+      it 'returns false when passing a default sorting key' do
+        expect(described_class.available?('unknown')).to be(false)
+      end
+    end
+
+    context 'when widget list is given' do
+      let_it_be(:widget_list) { [WorkItems::Widgets::Milestone] }
+
+      it 'returns true when passing a default sorting key' do
+        sorting_key = widget_list.sample.sorting_keys.keys.sample
+        expect(described_class.available?(sorting_key, widget_list: widget_list)).to be(true)
+      end
+
+      it 'returns false when passing an unknown sorting key' do
+        expect(described_class.available?('unknown', widget_list: widget_list)).to be(false)
+      end
+    end
+  end
+end
diff --git a/spec/models/work_items/types/user_preference_spec.rb b/spec/models/work_items/types/user_preference_spec.rb
deleted file mode 100644
index feb39fcff0d6..000000000000
--- a/spec/models/work_items/types/user_preference_spec.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe WorkItems::Types::UserPreference, type: :model, feature_category: :team_planning do
-  describe 'associations' do
-    it { is_expected.to belong_to(:user) }
-    it { is_expected.to belong_to(:namespace) }
-    it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type').inverse_of(:user_preferences) }
-  end
-end
diff --git a/spec/models/work_items/user_preference_spec.rb b/spec/models/work_items/user_preference_spec.rb
new file mode 100644
index 000000000000..e61c85293f52
--- /dev/null
+++ b/spec/models/work_items/user_preference_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::UserPreference, type: :model, feature_category: :team_planning do
+  describe 'associations' do
+    it { is_expected.to belong_to(:user) }
+    it { is_expected.to belong_to(:namespace) }
+    it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type').inverse_of(:user_preferences) }
+  end
+
+  describe 'validations' do
+    let_it_be(:namespace) { build_stubbed(:group) }
+
+    describe 'validate sort' do
+      let_it_be(:sorting_value) { 'due_date_asc' }
+
+      it 'is valid when the sorting value is available' do
+        preferences = described_class.new(namespace: namespace, sort: sorting_value)
+
+        expect(WorkItems::SortingKeys)
+          .to receive(:available?).with(sorting_value, widget_list: nil)
+            .and_return(true)
+
+        expect(preferences).to be_valid
+      end
+
+      it 'is not valid when the sorting value is not available' do
+        preferences = described_class.new(namespace: namespace, sort: sorting_value)
+
+        expect(WorkItems::SortingKeys)
+          .to receive(:available?).with(sorting_value, widget_list: nil)
+            .and_return(false)
+
+        expect(preferences).not_to be_valid
+        expect(preferences.errors.full_messages_for(:sort)).to include(
+          %(Sort value "#{sorting_value}" is not available on #{namespace.full_path})
+        )
+      end
+
+      it 'is not valid when the sorting value is not available for an existign work item type' do
+        work_item_type = WorkItems::Type.default_by_type(:incident)
+        preferences = described_class.new(
+          namespace: namespace,
+          work_item_type: work_item_type,
+          sort: sorting_value
+        )
+
+        expect(WorkItems::SortingKeys)
+          .to receive(:available?).with(
+            sorting_value,
+            widget_list: work_item_type.widget_classes(namespace)
+          ).and_return(false)
+
+        expect(preferences).not_to be_valid
+        expect(preferences.errors.full_messages_for(:sort)).to include(<<~MESSAGE.squish)
+          Sort value "#{sorting_value}" is not available
+          on #{namespace.full_path} for work items type #{work_item_type.name}
+        MESSAGE
+      end
+    end
+  end
+end
diff --git a/spec/policies/work_items/user_preference_policy_spec.rb b/spec/policies/work_items/user_preference_policy_spec.rb
new file mode 100644
index 000000000000..9ebf50714a20
--- /dev/null
+++ b/spec/policies/work_items/user_preference_policy_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::UserPreferencePolicy, feature_category: :team_planning do
+  let_it_be(:user) { create(:user) }
+
+  subject(:policy) { described_class.new(user, user_preference) }
+
+  context 'when namespace is public' do
+    context 'when namespace is a group' do
+      let_it_be(:namespace) { create(:group, :public) }
+      let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) }
+
+      it { is_expected.to be_allowed(:read_namespace) }
+    end
+
+    context 'when namespace is a project' do
+      let_it_be(:project) { create(:project, :public) }
+      let_it_be(:namespace) { project.project_namespace }
+      let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) }
+
+      it { is_expected.to be_allowed(:read_namespace) }
+    end
+  end
+
+  context 'when namespace is private' do
+    context 'when namespace is a group' do
+      let_it_be(:namespace) { create(:group, :private) }
+      let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) }
+
+      context 'when user is not member of the namespace' do
+        it { is_expected.to be_disallowed(:read_namespace) }
+      end
+
+      context 'when user is member of the namespace' do
+        before_all do
+          namespace.add_guest(user)
+        end
+
+        it { is_expected.to be_allowed(:read_namespace) }
+      end
+    end
+
+    context 'when namespace is a project' do
+      let_it_be(:project) { create(:project, :private) }
+      let_it_be(:namespace) { project.project_namespace }
+      let_it_be(:user_preference) { create(:work_item_user_preference, namespace: namespace, user: user) }
+
+      context 'when user is not member of the namespace' do
+        it { is_expected.to be_disallowed(:read_namespace) }
+      end
+
+      context 'when user is member of the namespace' do
+        before_all do
+          project.add_guest(user)
+        end
+
+        it { is_expected.to be_allowed(:read_namespace) }
+      end
+    end
+  end
+end
diff --git a/spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb
new file mode 100644
index 000000000000..439ac704eda0
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/work_items/user_preference/update_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe 'Update work items user preferences', feature_category: :team_planning do
+  include GraphqlHelpers
+
+  let_it_be(:user) { create(:user) }
+  let_it_be(:namespace) { create(:group, :private) }
+  let_it_be(:work_item_type) { WorkItems::Type.default_by_type(:incident) }
+
+  let(:sorting_value) { 'CREATED_ASC' }
+
+  let(:input) do
+    {
+      namespacePath: namespace.full_path,
+      workItemTypeId: work_item_type&.to_gid,
+      sort: sorting_value
+    }
+  end
+
+  let(:fields) do
+    <<~FIELDS
+    errors
+    userPreferences {
+      namespace {
+        path
+      }
+      workItemType {
+        name
+      }
+      sort
+    }
+    FIELDS
+  end
+
+  let(:mutation) { graphql_mutation(:WorkItemUserPreferenceUpdate, input, fields) }
+  let(:mutation_response) { graphql_mutation_response(:work_item_user_preference_update) }
+
+  shared_examples 'updating work items user preferences' do
+    context 'when user does not have access to the namespace' do
+      it 'does not update the user preference and return access error' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_errors.first['message']).to eq(
+          Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
+        )
+      end
+    end
+
+    context 'when user has access to the namespace' do
+      before_all do
+        namespace.add_guest(user)
+      end
+
+      it 'updates the user preferences successfully' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(response).to have_gitlab_http_status(:success)
+        expect(graphql_errors).to be_blank
+        expect(mutation_response['errors']).to be_blank
+        expect(mutation_response['userPreferences']).to eq(
+          'namespace' => {
+            'path' => namespace.path
+          },
+          'workItemType' => {
+            'name' => work_item_type.name
+          },
+          'sort' => sorting_value
+        )
+      end
+
+      context 'when work item type id is not provided' do
+        let(:input) do
+          {
+            namespacePath: namespace.full_path,
+            sort: sorting_value
+          }
+        end
+
+        it 'updates the user preferences successfully' do
+          post_graphql_mutation(mutation, current_user: user)
+
+          expect(response).to have_gitlab_http_status(:success)
+          expect(graphql_errors).to be_blank
+          expect(mutation_response['errors']).to be_blank
+          expect(mutation_response['userPreferences']).to eq(
+            'namespace' => {
+              'path' => namespace.path
+            },
+            'workItemType' => nil,
+            'sort' => sorting_value
+          )
+        end
+      end
+
+      context 'when sort value is not available' do
+        let_it_be(:sorting_value) { 'DUE_DATE_ASC' }
+
+        it 'updates the user preferences successfully' do
+          post_graphql_mutation(mutation, current_user: user)
+
+          expect(response).to have_gitlab_http_status(:success)
+          expect(graphql_errors).to be_blank
+          expect(mutation_response['errors']).to include(<<~MESSAGE.squish)
+          Sort value "#{sorting_value.downcase}" is not available
+          on #{namespace.full_path} for work items type #{work_item_type.name}
+          MESSAGE
+          expect(mutation_response['userPreferences']).to be_nil
+        end
+      end
+    end
+  end
+
+  context 'when namespace is a group' do
+    let_it_be(:namespace) { create(:group, :private) }
+
+    it_behaves_like 'updating work items user preferences'
+  end
+
+  context 'when namespace is a project' do
+    let_it_be(:namespace) { create(:project, :private) }
+
+    it_behaves_like 'updating work items user preferences'
+  end
+end
diff --git a/spec/workers/work_items/user_preferences/destroy_worker_spec.rb b/spec/workers/work_items/user_preferences/destroy_worker_spec.rb
new file mode 100644
index 000000000000..ed0d8432c2aa
--- /dev/null
+++ b/spec/workers/work_items/user_preferences/destroy_worker_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::UserPreferences::DestroyWorker, type: :worker, feature_category: :team_planning do
+  let_it_be(:root_namespace) { create(:group) }
+  let_it_be(:user) { create(:user) }
+
+  let(:source) { root_namespace }
+  let(:source_type) { GroupMember::SOURCE_TYPE }
+
+  let(:event) do
+    ::Members::DestroyedEvent.new(
+      data: {
+        root_namespace_id: root_namespace.id,
+        source_id: source.id,
+        source_type: source_type,
+        user_id: user.id
+      }
+    )
+  end
+
+  subject(:worker) { described_class.new }
+
+  it_behaves_like 'subscribes to event'
+
+  shared_examples 'delete user preferences' do
+    context 'when there user has no work item preference' do
+      it 'does nothing' do
+        expect { consume_event(subscriber: described_class, event: event) }
+          .not_to change { WorkItems::UserPreference.count }
+      end
+    end
+
+    context 'when there user has work item preference' do
+      let_it_be(:user_preference) do
+        create(:work_item_user_preference, namespace: namespace, user: user)
+      end
+
+      it 'destroy the existing user preference' do
+        expect { consume_event(subscriber: described_class, event: event) }
+          .to change { WorkItems::UserPreference.count }.by(-1)
+
+        expect(WorkItems::UserPreference.exists?(id: user_preference.id)).to be(false)
+      end
+    end
+  end
+
+  context 'when namespace is a group' do
+    let_it_be(:namespace) { create(:group, parent: root_namespace) }
+    let(:source) { namespace }
+
+    it_behaves_like 'delete user preferences'
+  end
+
+  context 'when namespace is a project' do
+    let_it_be(:project) { create(:project, group: root_namespace) }
+    let_it_be(:namespace) { project.project_namespace }
+
+    let(:source) { project }
+    let(:source_type) { ProjectMember::SOURCE_TYPE }
+
+    it_behaves_like 'delete user preferences'
+  end
+end
-- 
GitLab