From 1b8bfe6e38b21ef36e16566ae21086cf75b19202 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 4 May 2026 16:42:07 +0200 Subject: [PATCH 1/9] feat: added user organisation pins migrations --- ...504153000_create_user_organization_pins.rb | 16 +++++++++ db/schema_migrations/20260504153000 | 1 + db/structure.sql | 33 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 db/migrate/20260504153000_create_user_organization_pins.rb create mode 100644 db/schema_migrations/20260504153000 diff --git a/db/migrate/20260504153000_create_user_organization_pins.rb b/db/migrate/20260504153000_create_user_organization_pins.rb new file mode 100644 index 00000000..f9b7a491 --- /dev/null +++ b/db/migrate/20260504153000_create_user_organization_pins.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateUserOrganizationPins < Code0::ZeroTrack::Database::Migration[1.0] + def change + create_table :user_organization_pins do |t| + t.references :user, null: false, foreign_key: { on_delete: :cascade }, index: false + t.references :organization, null: false, foreign_key: { on_delete: :cascade }, index: false + t.integer :priority, null: false + + t.index %i[user_id organization_id], unique: true + t.index %i[user_id priority], unique: true + + t.timestamps_with_timezone + end + end +end diff --git a/db/schema_migrations/20260504153000 b/db/schema_migrations/20260504153000 new file mode 100644 index 00000000..3585e664 --- /dev/null +++ b/db/schema_migrations/20260504153000 @@ -0,0 +1 @@ +7cf08006d8ab53c1cea495d14f91286debd633d327ea0dee7148ce805d61c32d \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 8caeac2f..f433993d 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -846,6 +846,24 @@ CREATE SEQUENCE user_identities_id_seq ALTER SEQUENCE user_identities_id_seq OWNED BY user_identities.id; +CREATE TABLE user_organization_pins ( + id bigint NOT NULL, + user_id bigint NOT NULL, + organization_id bigint NOT NULL, + priority integer NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE user_organization_pins_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE user_organization_pins_id_seq OWNED BY user_organization_pins.id; + CREATE TABLE user_sessions ( id bigint NOT NULL, user_id bigint NOT NULL, @@ -971,6 +989,8 @@ ALTER TABLE ONLY translations ALTER COLUMN id SET DEFAULT nextval('translations_ ALTER TABLE ONLY user_identities ALTER COLUMN id SET DEFAULT nextval('user_identities_id_seq'::regclass); +ALTER TABLE ONLY user_organization_pins ALTER COLUMN id SET DEFAULT nextval('user_organization_pins_id_seq'::regclass); + ALTER TABLE ONLY user_sessions ALTER COLUMN id SET DEFAULT nextval('user_sessions_id_seq'::regclass); ALTER TABLE ONLY users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass); @@ -1116,6 +1136,9 @@ ALTER TABLE ONLY translations ALTER TABLE ONLY user_identities ADD CONSTRAINT user_identities_pkey PRIMARY KEY (id); +ALTER TABLE ONLY user_organization_pins + ADD CONSTRAINT user_organization_pins_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_sessions ADD CONSTRAINT user_sessions_pkey PRIMARY KEY (id); @@ -1276,6 +1299,10 @@ CREATE INDEX index_user_identities_on_user_id ON user_identities USING btree (us CREATE UNIQUE INDEX index_user_identities_on_user_id_and_provider_id ON user_identities USING btree (user_id, provider_id); +CREATE UNIQUE INDEX index_user_organization_pins_on_user_id_and_organization_id ON user_organization_pins USING btree (user_id, organization_id); + +CREATE UNIQUE INDEX index_user_organization_pins_on_user_id_and_priority ON user_organization_pins USING btree (user_id, priority); + CREATE UNIQUE INDEX index_user_sessions_on_token ON user_sessions USING btree (token); CREATE INDEX index_user_sessions_on_user_id ON user_sessions USING btree (user_id); @@ -1286,6 +1313,9 @@ CREATE UNIQUE INDEX "index_users_on_LOWER_username" ON users USING btree (lower( CREATE UNIQUE INDEX index_users_on_totp_secret ON users USING btree (totp_secret) WHERE (totp_secret IS NOT NULL); +ALTER TABLE ONLY user_organization_pins + ADD CONSTRAINT fk_rails_036679312e FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY node_parameters ADD CONSTRAINT fk_rails_0d79310cfa FOREIGN KEY (node_function_id) REFERENCES node_functions(id) ON DELETE CASCADE; @@ -1364,6 +1394,9 @@ ALTER TABLE ONLY flow_types ALTER TABLE ONLY namespace_role_project_assignments ADD CONSTRAINT fk_rails_69066bda8f FOREIGN KEY (project_id) REFERENCES namespace_projects(id); +ALTER TABLE ONLY user_organization_pins + ADD CONSTRAINT fk_rails_6b125cdf79 FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY namespace_member_roles ADD CONSTRAINT fk_rails_6c0d5a04c4 FOREIGN KEY (member_id) REFERENCES namespace_members(id) ON DELETE CASCADE; From 65a8ca00f2ae14a4db74d7ea0c1f67fc6c178aa3 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 4 May 2026 16:42:27 +0200 Subject: [PATCH 2/9] feat: added pin organisation user service --- .../users/update_organization_pins.rb | 33 +++++++++++ app/graphql/types/mutation_type.rb | 1 + .../types/user_organization_pin_type.rb | 16 ++++++ app/graphql/types/user_type.rb | 16 ++++++ app/models/organization.rb | 3 + app/models/user.rb | 2 + app/models/user_organization_pin.rb | 10 ++++ app/policies/user_organization_pin_policy.rb | 7 +++ app/policies/user_policy.rb | 5 +- .../users/update_organization_pins_service.rb | 57 +++++++++++++++++++ 10 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 app/graphql/mutations/users/update_organization_pins.rb create mode 100644 app/graphql/types/user_organization_pin_type.rb create mode 100644 app/models/user_organization_pin.rb create mode 100644 app/policies/user_organization_pin_policy.rb create mode 100644 app/services/users/update_organization_pins_service.rb diff --git a/app/graphql/mutations/users/update_organization_pins.rb b/app/graphql/mutations/users/update_organization_pins.rb new file mode 100644 index 00000000..bbfc5b73 --- /dev/null +++ b/app/graphql/mutations/users/update_organization_pins.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Users + class UpdateOrganizationPins < BaseMutation + description 'Update pinned organizations for a user.' + + field :user, Types::UserType, null: true, description: 'The updated user.' + + argument :user_id, Types::GlobalIdType[::User], required: true, description: 'ID of the user to update.' + argument :organization_ids, + [Types::GlobalIdType[::Organization]], + required: true, + description: 'Ordered list of organization IDs to pin for the user.' + + def resolve(user_id:, organization_ids:) + user = SagittariusSchema.object_from_id(user_id) + return { user: nil, errors: [create_error(:user_not_found, 'Invalid user with provided id')] } if user.nil? + + organizations = organization_ids.map { |id| SagittariusSchema.object_from_id(id) } + if organizations.any?(&:nil?) + return { user: nil, errors: [create_error(:organization_not_found, 'Invalid organization with provided id')] } + end + + ::Users::UpdateOrganizationPinsService.new( + current_authentication, + user, + organizations.map(&:id) + ).execute.to_mutation_response(success_key: :user) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b3833d21..b49b78b8 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -44,6 +44,7 @@ class MutationType < Types::BaseObject mount_mutation Mutations::Users::PasswordResetRequest mount_mutation Mutations::Users::PasswordReset mount_mutation Mutations::Users::Register + mount_mutation Mutations::Users::UpdateOrganizationPins mount_mutation Mutations::Users::Update mount_mutation Mutations::Echo end diff --git a/app/graphql/types/user_organization_pin_type.rb b/app/graphql/types/user_organization_pin_type.rb new file mode 100644 index 00000000..1b7b927e --- /dev/null +++ b/app/graphql/types/user_organization_pin_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class UserOrganizationPinType < Types::BaseObject + description 'Represents a pinned organization of a user' + + authorize :read_user_organization_pin + + field :organization, Types::OrganizationType, null: false, description: 'The pinned organization' + field :priority, Integer, null: false, description: 'Ordering priority of the pin' + field :user, Types::UserType, null: false, description: 'The user owning this pin' + + id_field UserOrganizationPin + timestamps + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 4e2f427e..2f8820c1 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -41,6 +41,14 @@ class UserType < Types::BaseObject description: 'Identities of this user', method: :user_identities + field :organization_pins, [Types::UserOrganizationPinType], + null: false, + description: 'Pinned organizations of this user with explicit priority' + + field :pinned_organizations, [Types::OrganizationType], + null: false, + description: 'Pinned organizations of this user ordered by pin priority' + field :mfa_status, Types::MfaStatusType, null: true, description: 'Multi-factor authentication status of this user' @@ -71,5 +79,13 @@ def mfa_status backup_codes_count: object.backup_codes.size, } end + + def organization_pins + object.user_organization_pins.order(priority: :asc) + end + + def pinned_organizations + organization_pins.map(&:organization) + end end end diff --git a/app/models/organization.rb b/app/models/organization.rb index 90c15354..83186d62 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -3,6 +3,9 @@ class Organization < ApplicationRecord include NamespaceParent + has_many :user_organization_pins, inverse_of: :organization, dependent: :delete_all + has_many :pinned_by_users, through: :user_organization_pins, source: :user + validates :name, presence: true, length: { minimum: 3, maximum: 50 }, allow_blank: false, diff --git a/app/models/user.rb b/app/models/user.rb index f6d24f8b..1259e7f0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -27,6 +27,8 @@ class User < ApplicationRecord has_many :namespaces, through: :namespace_memberships, inverse_of: :users has_many :user_identities, inverse_of: :user + has_many :user_organization_pins, -> { order(priority: :asc) }, inverse_of: :user, dependent: :delete_all + has_many :pinned_organizations, through: :user_organization_pins, source: :organization has_one_attached :avatar diff --git a/app/models/user_organization_pin.rb b/app/models/user_organization_pin.rb new file mode 100644 index 00000000..378e7572 --- /dev/null +++ b/app/models/user_organization_pin.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class UserOrganizationPin < ApplicationRecord + belongs_to :user, inverse_of: :user_organization_pins + belongs_to :organization, inverse_of: :user_organization_pins + + validates :priority, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :organization_id, uniqueness: { scope: :user_id } + validates :priority, uniqueness: { scope: :user_id } +end diff --git a/app/policies/user_organization_pin_policy.rb b/app/policies/user_organization_pin_policy.rb new file mode 100644 index 00000000..be19f394 --- /dev/null +++ b/app/policies/user_organization_pin_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UserOrganizationPinPolicy < BasePolicy + delegate { subject.user } + + rule { can?(:read_user) }.enable :read_user_organization_pin +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 149c94e3..ca42b813 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -5,7 +5,10 @@ class UserPolicy < BasePolicy condition(:user_is_admin) { user&.admin? || false } condition(:admin_status_visible) { ApplicationSetting.current[:admin_status_visible] } - rule { ~anonymous }.enable :read_user + rule { ~anonymous }.policy do + enable :read_user + enable :read_user_organization_pin + end rule { user_is_admin }.policy do enable :update_user diff --git a/app/services/users/update_organization_pins_service.rb b/app/services/users/update_organization_pins_service.rb new file mode 100644 index 00000000..5a5f6a1d --- /dev/null +++ b/app/services/users/update_organization_pins_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Users + class UpdateOrganizationPinsService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :user, :organization_ids + + def initialize(current_authentication, user, organization_ids) + @current_authentication = current_authentication + @user = user + @organization_ids = organization_ids.uniq + end + + def execute + unless Ability.allowed?(current_authentication, :update_user, user) + return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) + end + + organizations = Organization.where(id: organization_ids) + if organizations.count != organization_ids.count + return ServiceResponse.error(message: 'Organization not found', error_code: :organization_not_found) + end + + transactional do |t| + old_pins = user.user_organization_pins.map { |pin| { organization_id: pin.organization_id, priority: pin.priority } } + + user.user_organization_pins.delete_all + + organization_ids.each_with_index do |organization_id, priority| + pin = user.user_organization_pins.build(organization_id: organization_id, priority: priority) + next if pin.save + + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to update user organization pins', + error_code: :invalid_user, + details: pin.errors + ) + end + + new_pins = user.user_organization_pins.reload.map do |pin| + { organization_id: pin.organization_id, priority: pin.priority } + end + + AuditService.audit( + :user_updated, + author_id: current_authentication.user.id, + entity: user, + target: user, + details: { old_pins: old_pins, new_pins: new_pins } + ) + + ServiceResponse.success(message: 'Updated user organization pins', payload: user) + end + end + end +end From 27b575cd8107877b6eebfa246ac8454279446d99 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 4 May 2026 16:42:30 +0200 Subject: [PATCH 3/9] docs: regen --- .../mutation/usersupdateorganizationpins.md | 21 +++++++++++++++++++ docs/graphql/object/user.md | 2 ++ docs/graphql/object/userorganizationpin.md | 16 ++++++++++++++ docs/graphql/scalar/userorganizationpinid.md | 5 +++++ 4 files changed, 44 insertions(+) create mode 100644 docs/graphql/mutation/usersupdateorganizationpins.md create mode 100644 docs/graphql/object/userorganizationpin.md create mode 100644 docs/graphql/scalar/userorganizationpinid.md diff --git a/docs/graphql/mutation/usersupdateorganizationpins.md b/docs/graphql/mutation/usersupdateorganizationpins.md new file mode 100644 index 00000000..2af08603 --- /dev/null +++ b/docs/graphql/mutation/usersupdateorganizationpins.md @@ -0,0 +1,21 @@ +--- +title: usersUpdateOrganizationPins +--- + +Update pinned organizations for a user. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `organizationIds` | [`[OrganizationID!]!`](../scalar/organizationid.md) | Ordered list of organization IDs to pin for the user. | +| `userId` | [`UserID!`](../scalar/userid.md) | ID of the user to update. | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | +| `user` | [`User`](../object/user.md) | The updated user. | diff --git a/docs/graphql/object/user.md b/docs/graphql/object/user.md index 16ca68de..5101f753 100644 --- a/docs/graphql/object/user.md +++ b/docs/graphql/object/user.md @@ -20,6 +20,8 @@ Represents a user | `mfaStatus` | [`MfaStatus`](../object/mfastatus.md) | Multi-factor authentication status of this user | | `namespace` | [`Namespace`](../object/namespace.md) | Namespace of this user | | `namespaceMemberships` | [`NamespaceMemberConnection!`](../object/namespacememberconnection.md) | Namespace Memberships of this user | +| `organizationPins` | [`[UserOrganizationPin!]!`](../object/userorganizationpin.md) | Pinned organizations of this user with explicit priority | +| `pinnedOrganizations` | [`[Organization!]!`](../object/organization.md) | Pinned organizations of this user ordered by pin priority | | `sessions` | [`UserSessionConnection!`](../object/usersessionconnection.md) | Sessions of this user | | `updatedAt` | [`Time!`](../scalar/time.md) | Time when this User was last updated | | `userAbilities` | [`UserUserAbilities!`](../object/useruserabilities.md) | Abilities for the current user on this User | diff --git a/docs/graphql/object/userorganizationpin.md b/docs/graphql/object/userorganizationpin.md new file mode 100644 index 00000000..f7c1a0f9 --- /dev/null +++ b/docs/graphql/object/userorganizationpin.md @@ -0,0 +1,16 @@ +--- +title: UserOrganizationPin +--- + +Represents a pinned organization of a user + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `createdAt` | [`Time!`](../scalar/time.md) | Time when this UserOrganizationPin was created | +| `id` | [`UserOrganizationPinID!`](../scalar/userorganizationpinid.md) | Global ID of this UserOrganizationPin | +| `organization` | [`Organization!`](../object/organization.md) | The pinned organization | +| `priority` | [`Int!`](../scalar/int.md) | Ordering priority of the pin | +| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this UserOrganizationPin was last updated | +| `user` | [`User!`](../object/user.md) | The user owning this pin | diff --git a/docs/graphql/scalar/userorganizationpinid.md b/docs/graphql/scalar/userorganizationpinid.md new file mode 100644 index 00000000..bb77ff0d --- /dev/null +++ b/docs/graphql/scalar/userorganizationpinid.md @@ -0,0 +1,5 @@ +--- +title: UserOrganizationPinID +--- + +A unique identifier for all UserOrganizationPin entities of the application From 169e9764a509401ad2a2e5e29324de86e9801880 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 4 May 2026 16:42:42 +0200 Subject: [PATCH 4/9] feat: added specs for user organisation pins --- spec/factories/user_organization_pins.rb | 11 ++++ spec/factories/users.rb | 10 +++ .../users/update_organization_pins_spec.rb | 7 +++ .../types/user_organization_pin_type_spec.rb | 20 ++++++ spec/graphql/types/user_type_spec.rb | 2 + spec/models/organization_spec.rb | 5 ++ spec/models/user_organization_pin_spec.rb | 18 ++++++ spec/models/user_spec.rb | 2 + spec/policies/user_policy_spec.rb | 2 + .../users/update_organization_pins_spec.rb | 61 +++++++++++++++++++ .../update_organization_pins_service_spec.rb | 58 ++++++++++++++++++ 11 files changed, 196 insertions(+) create mode 100644 spec/factories/user_organization_pins.rb create mode 100644 spec/graphql/mutations/users/update_organization_pins_spec.rb create mode 100644 spec/graphql/types/user_organization_pin_type_spec.rb create mode 100644 spec/models/user_organization_pin_spec.rb create mode 100644 spec/requests/graphql/mutation/users/update_organization_pins_spec.rb create mode 100644 spec/services/users/update_organization_pins_service_spec.rb diff --git a/spec/factories/user_organization_pins.rb b/spec/factories/user_organization_pins.rb new file mode 100644 index 00000000..e7c11de4 --- /dev/null +++ b/spec/factories/user_organization_pins.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + sequence(:user_organization_pin_priority) + + factory :user_organization_pin do + user + organization + priority { generate(:user_organization_pin_priority) } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 66c26f86..02b762a9 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -21,5 +21,15 @@ trait :with_namespace do after :build, &:ensure_namespace end + + trait :with_organization_pins do + transient do + organization_pins_count { 2 } + end + + after :create do |user, evaluator| + create_list(:user_organization_pin, evaluator.organization_pins_count, user: user) + end + end end end diff --git a/spec/graphql/mutations/users/update_organization_pins_spec.rb b/spec/graphql/mutations/users/update_organization_pins_spec.rb new file mode 100644 index 00000000..27054439 --- /dev/null +++ b/spec/graphql/mutations/users/update_organization_pins_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Users::UpdateOrganizationPins do + it { expect(described_class.graphql_name).to eq('UsersUpdateOrganizationPins') } +end diff --git a/spec/graphql/types/user_organization_pin_type_spec.rb b/spec/graphql/types/user_organization_pin_type_spec.rb new file mode 100644 index 00000000..267b39a2 --- /dev/null +++ b/spec/graphql/types/user_organization_pin_type_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['UserOrganizationPin'] do + let(:fields) do + %w[ + id + user + organization + priority + createdAt + updatedAt + ] + end + + it { expect(described_class.graphql_name).to eq('UserOrganizationPin') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_user_organization_pin) } +end diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index 361d0193..f4e45b30 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -17,6 +17,8 @@ emailVerifiedAt sessions identities + organizationPins + pinnedOrganizations mfaStatus userAbilities createdAt diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index b646f519..ccd68b78 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -5,6 +5,11 @@ RSpec.describe Organization do subject { create(:organization) } + describe 'associations' do + it { is_expected.to have_many(:user_organization_pins).inverse_of(:organization) } + it { is_expected.to have_many(:pinned_by_users).through(:user_organization_pins).source(:user) } + end + describe 'validations' do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_uniqueness_of(:name).case_insensitive } diff --git a/spec/models/user_organization_pin_spec.rb b/spec/models/user_organization_pin_spec.rb new file mode 100644 index 00000000..9aad9b7f --- /dev/null +++ b/spec/models/user_organization_pin_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserOrganizationPin do + subject { create(:user_organization_pin) } + + describe 'associations' do + it { is_expected.to belong_to(:user).inverse_of(:user_organization_pins) } + it { is_expected.to belong_to(:organization).inverse_of(:user_organization_pins) } + end + + describe 'validations' do + it { is_expected.to validate_numericality_of(:priority).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_uniqueness_of(:organization_id).scoped_to(:user_id) } + it { is_expected.to validate_uniqueness_of(:priority).scoped_to(:user_id) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 51c4cb12..b13b3eca 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -10,6 +10,8 @@ it { is_expected.to have_many(:authored_audit_events).class_name('AuditEvent').inverse_of(:author) } it { is_expected.to have_many(:namespace_memberships).class_name('NamespaceMember').inverse_of(:user) } it { is_expected.to have_many(:namespaces).through(:namespace_memberships).inverse_of(:users) } + it { is_expected.to have_many(:user_organization_pins).inverse_of(:user) } + it { is_expected.to have_many(:pinned_organizations).through(:user_organization_pins).source(:organization) } end describe 'validations' do diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index c3a33169..9348ca34 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -11,11 +11,13 @@ let(:current_user) { create(:user) } it { is_expected.to be_allowed(:read_user) } + it { is_expected.to be_allowed(:read_user_organization_pin) } end context 'when user is nil' do let(:current_user) { nil } it { is_expected.not_to be_allowed(:read_user) } + it { is_expected.not_to be_allowed(:read_user_organization_pin) } end end diff --git a/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb b/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb new file mode 100644 index 00000000..58ee58e6 --- /dev/null +++ b/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'usersUpdateOrganizationPins Mutation' do + include GraphqlHelpers + + let(:mutation) do + <<~QUERY + mutation($input: UsersUpdateOrganizationPinsInput!) { + usersUpdateOrganizationPins(input: $input) { + #{error_query} + user { + id + organizationPins { + priority + } + } + } + } + QUERY + end + + let(:current_user) { create(:user) } + let(:target_user) { current_user } + let(:organization_a) { create(:organization) } + let(:organization_b) { create(:organization) } + let(:input) do + { + userId: target_user.to_global_id.to_s, + organizationIds: [organization_a.to_global_id.to_s, organization_b.to_global_id.to_s], + } + end + let(:variables) { { input: input } } + + before do + post_graphql mutation, variables: variables, current_user: current_user + end + + it 'updates organization pins in requested order' do + expect(graphql_data_at(:users_update_organization_pins, :user, :id)).to eq(target_user.to_global_id.to_s) + + pins = target_user.reload.user_organization_pins.order(:priority) + expect(pins.pluck(:organization_id)).to eq([organization_a.id, organization_b.id]) + expect(pins.pluck(:priority)).to eq([0, 1]) + end + + context 'when one organization id is invalid' do + let(:input) do + { + userId: target_user.to_global_id.to_s, + organizationIds: [organization_a.to_global_id.to_s, 'gid://sagittarius/Organization/999999'], + } + end + + it 'returns an error' do + expect(graphql_data_at(:users_update_organization_pins, :user)).to be_nil + expect(graphql_data_at(:users_update_organization_pins, :errors, :error_code)).to include('ORGANIZATION_NOT_FOUND') + end + end +end diff --git a/spec/services/users/update_organization_pins_service_spec.rb b/spec/services/users/update_organization_pins_service_spec.rb new file mode 100644 index 00000000..ce8ab8ee --- /dev/null +++ b/spec/services/users/update_organization_pins_service_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::UpdateOrganizationPinsService do + subject(:service_response) do + described_class.new(create_authentication(current_user), user, organization_ids).execute + end + + let(:user) { create(:user) } + let(:organization_a) { create(:organization) } + let(:organization_b) { create(:organization) } + let(:organization_ids) { [organization_a.id, organization_b.id] } + + context 'when current user cannot update target user' do + let(:current_user) { nil } + + it { is_expected.to be_error } + it { expect(service_response.payload[:error_code]).to eq(:missing_permission) } + it { expect { service_response }.not_to create_audit_event } + end + + context 'when an organization does not exist' do + let(:current_user) { user } + let(:organization_ids) { [organization_a.id, 999_999] } + + it { is_expected.to be_error } + it { expect(service_response.payload[:error_code]).to eq(:organization_not_found) } + it { expect { service_response }.not_to create_audit_event } + end + + context 'when input is valid' do + let(:current_user) { user } + + before do + create(:user_organization_pin, user: user, organization: create(:organization), priority: 0) + end + + it { is_expected.to be_success } + + it 'replaces pins in given order with priorities' do + service_response + + pins = user.reload.user_organization_pins.order(:priority) + expect(pins.pluck(:organization_id)).to eq(organization_ids) + expect(pins.pluck(:priority)).to eq([0, 1]) + end + + it 'creates an audit event' do + expect { service_response }.to create_audit_event( + :user_updated, + author_id: current_user.id, + entity_type: 'User', + target_type: 'User' + ) + end + end +end From cc62ab64f9ffb22421d381debf628a3683a5b921 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 4 May 2026 17:32:57 +0200 Subject: [PATCH 5/9] ref: satisfied rubocop --- app/graphql/mutations/users/update_organization_pins.rb | 2 +- app/services/users/update_organization_pins_service.rb | 4 +++- .../graphql/mutation/users/update_organization_pins_spec.rb | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/graphql/mutations/users/update_organization_pins.rb b/app/graphql/mutations/users/update_organization_pins.rb index bbfc5b73..d903af74 100644 --- a/app/graphql/mutations/users/update_organization_pins.rb +++ b/app/graphql/mutations/users/update_organization_pins.rb @@ -7,11 +7,11 @@ class UpdateOrganizationPins < BaseMutation field :user, Types::UserType, null: true, description: 'The updated user.' - argument :user_id, Types::GlobalIdType[::User], required: true, description: 'ID of the user to update.' argument :organization_ids, [Types::GlobalIdType[::Organization]], required: true, description: 'Ordered list of organization IDs to pin for the user.' + argument :user_id, Types::GlobalIdType[::User], required: true, description: 'ID of the user to update.' def resolve(user_id:, organization_ids:) user = SagittariusSchema.object_from_id(user_id) diff --git a/app/services/users/update_organization_pins_service.rb b/app/services/users/update_organization_pins_service.rb index 5a5f6a1d..de9738a8 100644 --- a/app/services/users/update_organization_pins_service.rb +++ b/app/services/users/update_organization_pins_service.rb @@ -23,7 +23,9 @@ def execute end transactional do |t| - old_pins = user.user_organization_pins.map { |pin| { organization_id: pin.organization_id, priority: pin.priority } } + old_pins = user.user_organization_pins.map do |pin| + { organization_id: pin.organization_id, priority: pin.priority } + end user.user_organization_pins.delete_all diff --git a/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb b/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb index 58ee58e6..a0eb3734 100644 --- a/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb +++ b/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb @@ -55,7 +55,8 @@ it 'returns an error' do expect(graphql_data_at(:users_update_organization_pins, :user)).to be_nil - expect(graphql_data_at(:users_update_organization_pins, :errors, :error_code)).to include('ORGANIZATION_NOT_FOUND') + expect(graphql_data_at(:users_update_organization_pins, :errors, + :error_code)).to include('ORGANIZATION_NOT_FOUND') end end end From e3db55b89cf03df056928c107e1c576342f4f602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raphael=20G=C3=B6tz?= <52959657+raphael-goetz@users.noreply.github.com> Date: Mon, 11 May 2026 17:06:47 +0200 Subject: [PATCH 6/9] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Niklas van Schrick Signed-off-by: Raphael Götz <52959657+raphael-goetz@users.noreply.github.com> --- app/graphql/types/user_organization_pin_type.rb | 2 +- app/graphql/types/user_type.rb | 2 +- app/models/organization.rb | 2 +- app/models/user.rb | 2 +- app/models/user_organization_pin.rb | 5 +++-- app/policies/user_organization_pin_policy.rb | 4 +++- app/policies/user_policy.rb | 5 +---- app/services/users/update_organization_pins_service.rb | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/graphql/types/user_organization_pin_type.rb b/app/graphql/types/user_organization_pin_type.rb index 1b7b927e..8cbc8fb7 100644 --- a/app/graphql/types/user_organization_pin_type.rb +++ b/app/graphql/types/user_organization_pin_type.rb @@ -6,7 +6,7 @@ class UserOrganizationPinType < Types::BaseObject authorize :read_user_organization_pin - field :organization, Types::OrganizationType, null: false, description: 'The pinned organization' + field :organization, Types::OrganizationType, null: true, description: 'The pinned organization' field :priority, Integer, null: false, description: 'Ordering priority of the pin' field :user, Types::UserType, null: false, description: 'The user owning this pin' diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 2f8820c1..a74048ba 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -85,7 +85,7 @@ def organization_pins end def pinned_organizations - organization_pins.map(&:organization) + object.pinned_organizations end end end diff --git a/app/models/organization.rb b/app/models/organization.rb index 83186d62..59aaaae2 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -3,7 +3,7 @@ class Organization < ApplicationRecord include NamespaceParent - has_many :user_organization_pins, inverse_of: :organization, dependent: :delete_all + has_many :user_organization_pins, inverse_of: :organization has_many :pinned_by_users, through: :user_organization_pins, source: :user validates :name, presence: true, diff --git a/app/models/user.rb b/app/models/user.rb index 1259e7f0..ba756e5c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -27,7 +27,7 @@ class User < ApplicationRecord has_many :namespaces, through: :namespace_memberships, inverse_of: :users has_many :user_identities, inverse_of: :user - has_many :user_organization_pins, -> { order(priority: :asc) }, inverse_of: :user, dependent: :delete_all + has_many :user_organization_pins, -> { order(priority: :asc) }, inverse_of: :user has_many :pinned_organizations, through: :user_organization_pins, source: :organization has_one_attached :avatar diff --git a/app/models/user_organization_pin.rb b/app/models/user_organization_pin.rb index 378e7572..6a267dbf 100644 --- a/app/models/user_organization_pin.rb +++ b/app/models/user_organization_pin.rb @@ -4,7 +4,8 @@ class UserOrganizationPin < ApplicationRecord belongs_to :user, inverse_of: :user_organization_pins belongs_to :organization, inverse_of: :user_organization_pins - validates :priority, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :priority, presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 }, + uniqueness: { scope: :user_id } validates :organization_id, uniqueness: { scope: :user_id } - validates :priority, uniqueness: { scope: :user_id } end diff --git a/app/policies/user_organization_pin_policy.rb b/app/policies/user_organization_pin_policy.rb index be19f394..b784f691 100644 --- a/app/policies/user_organization_pin_policy.rb +++ b/app/policies/user_organization_pin_policy.rb @@ -3,5 +3,7 @@ class UserOrganizationPinPolicy < BasePolicy delegate { subject.user } - rule { can?(:read_user) }.enable :read_user_organization_pin + condition(:user_is_self) { subject.id == user&.id } + + rule { user_is_self }.enable :read_user_organization_pin end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index ca42b813..149c94e3 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -5,10 +5,7 @@ class UserPolicy < BasePolicy condition(:user_is_admin) { user&.admin? || false } condition(:admin_status_visible) { ApplicationSetting.current[:admin_status_visible] } - rule { ~anonymous }.policy do - enable :read_user - enable :read_user_organization_pin - end + rule { ~anonymous }.enable :read_user rule { user_is_admin }.policy do enable :update_user diff --git a/app/services/users/update_organization_pins_service.rb b/app/services/users/update_organization_pins_service.rb index de9738a8..3dfe5189 100644 --- a/app/services/users/update_organization_pins_service.rb +++ b/app/services/users/update_organization_pins_service.rb @@ -17,7 +17,7 @@ def execute return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) end - organizations = Organization.where(id: organization_ids) + organizations = OrganizationsFinder.new(id: organization_ids, namespace_member_user: user).execute if organizations.count != organization_ids.count return ServiceResponse.error(message: 'Organization not found', error_code: :organization_not_found) end From a787ca2cfb8f9a9e854d40b2c2c18c3b587b89b3 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 11 May 2026 17:08:05 +0200 Subject: [PATCH 7/9] feat: removed user_id from mutation --- app/graphql/mutations/users/update_organization_pins.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/graphql/mutations/users/update_organization_pins.rb b/app/graphql/mutations/users/update_organization_pins.rb index d903af74..6dca7385 100644 --- a/app/graphql/mutations/users/update_organization_pins.rb +++ b/app/graphql/mutations/users/update_organization_pins.rb @@ -11,7 +11,6 @@ class UpdateOrganizationPins < BaseMutation [Types::GlobalIdType[::Organization]], required: true, description: 'Ordered list of organization IDs to pin for the user.' - argument :user_id, Types::GlobalIdType[::User], required: true, description: 'ID of the user to update.' def resolve(user_id:, organization_ids:) user = SagittariusSchema.object_from_id(user_id) From 1708f57315cbd39118875a5830a9db6801797f90 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 11 May 2026 17:32:07 +0200 Subject: [PATCH 8/9] feat: implemented requested changes --- .../users/update_organization_pins.rb | 6 +--- app/models/audit_event.rb | 1 + app/policies/user_organization_pin_policy.rb | 2 +- app/policies/user_policy.rb | 1 + app/services/error_code.rb | 1 + .../namespaces/members/delete_service.rb | 10 ++++++ .../users/update_organization_pins_service.rb | 12 +++---- .../mutation/usersupdateorganizationpins.md | 1 - .../user_organization_pin_policy_spec.rb | 27 +++++++++++++++ spec/policies/user_policy_spec.rb | 20 +++++++++-- .../users/update_organization_pins_spec.rb | 15 ++++---- .../namespaces/members/delete_service_spec.rb | 9 +++++ .../update_organization_pins_service_spec.rb | 34 ++++++++++++++++--- 13 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 spec/policies/user_organization_pin_policy_spec.rb diff --git a/app/graphql/mutations/users/update_organization_pins.rb b/app/graphql/mutations/users/update_organization_pins.rb index 6dca7385..13b18c0f 100644 --- a/app/graphql/mutations/users/update_organization_pins.rb +++ b/app/graphql/mutations/users/update_organization_pins.rb @@ -12,10 +12,7 @@ class UpdateOrganizationPins < BaseMutation required: true, description: 'Ordered list of organization IDs to pin for the user.' - def resolve(user_id:, organization_ids:) - user = SagittariusSchema.object_from_id(user_id) - return { user: nil, errors: [create_error(:user_not_found, 'Invalid user with provided id')] } if user.nil? - + def resolve(organization_ids:) organizations = organization_ids.map { |id| SagittariusSchema.object_from_id(id) } if organizations.any?(&:nil?) return { user: nil, errors: [create_error(:organization_not_found, 'Invalid organization with provided id')] } @@ -23,7 +20,6 @@ def resolve(user_id:, organization_ids:) ::Users::UpdateOrganizationPinsService.new( current_authentication, - user, organizations.map(&:id) ).execute.to_mutation_response(success_key: :user) end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 332d468a..be08a155 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -41,6 +41,7 @@ class AuditEvent < ApplicationRecord password_reset: 37, user_deleted: 38, user_created: 39, + user_organization_pins_updated: 40, }.with_indifferent_access # rubocop:disable Lint/StructNewOverride diff --git a/app/policies/user_organization_pin_policy.rb b/app/policies/user_organization_pin_policy.rb index b784f691..bb7033cb 100644 --- a/app/policies/user_organization_pin_policy.rb +++ b/app/policies/user_organization_pin_policy.rb @@ -3,7 +3,7 @@ class UserOrganizationPinPolicy < BasePolicy delegate { subject.user } - condition(:user_is_self) { subject.id == user&.id } + condition(:user_is_self) { subject.user_id == user&.id } rule { user_is_self }.enable :read_user_organization_pin end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 149c94e3..0096bc50 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -23,6 +23,7 @@ class UserPolicy < BasePolicy enable :read_user_identity enable :manage_mfa enable :update_user + enable :update_user_organization_pin enable :update_attachment_avatar enable :verify_email enable :send_verification_email diff --git a/app/services/error_code.rb b/app/services/error_code.rb index ca5af764..07e80ec9 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -41,6 +41,7 @@ def self.error_codes failed_to_save_valid_backup_code: { description: 'The new backup codes could not be saved' }, invalid_setting: { description: 'Invalid setting provided' }, invalid_user: { description: 'The user is invalid because of active model errors' }, + invalid_user_organization_pin: { description: 'The user organization pin is invalid because of active model errors' }, invalid_password_repeat: { description: 'The provided password repeat does not match the password' }, cannot_modify_admin: { description: 'Only administrators can modify admin status of users' }, cannot_modify_own_admin: { description: 'Users cannot modify their own admin status' }, diff --git a/app/services/namespaces/members/delete_service.rb b/app/services/namespaces/members/delete_service.rb index f89e9a2e..f3059880 100644 --- a/app/services/namespaces/members/delete_service.rb +++ b/app/services/namespaces/members/delete_service.rb @@ -27,6 +27,7 @@ def execute end check_last_administrator(t) + remove_organization_pin AuditService.audit( :namespace_member_deleted, @@ -54,6 +55,15 @@ def check_last_administrator(t) ) end end + + def remove_organization_pin + return unless namespace_member.namespace.organization_type? + + UserOrganizationPin.where( + user: namespace_member.user, + organization: namespace_member.namespace.parent + ).delete_all + end end end end diff --git a/app/services/users/update_organization_pins_service.rb b/app/services/users/update_organization_pins_service.rb index 3dfe5189..27e650f7 100644 --- a/app/services/users/update_organization_pins_service.rb +++ b/app/services/users/update_organization_pins_service.rb @@ -6,14 +6,14 @@ class UpdateOrganizationPinsService attr_reader :current_authentication, :user, :organization_ids - def initialize(current_authentication, user, organization_ids) + def initialize(current_authentication, organization_ids) @current_authentication = current_authentication - @user = user + @user = current_authentication.user @organization_ids = organization_ids.uniq end def execute - unless Ability.allowed?(current_authentication, :update_user, user) + unless user && Ability.allowed?(current_authentication, :update_user_organization_pin, user) return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) end @@ -27,7 +27,7 @@ def execute { organization_id: pin.organization_id, priority: pin.priority } end - user.user_organization_pins.delete_all + UserOrganizationPin.where(user: user).delete_all organization_ids.each_with_index do |organization_id, priority| pin = user.user_organization_pins.build(organization_id: organization_id, priority: priority) @@ -35,7 +35,7 @@ def execute t.rollback_and_return! ServiceResponse.error( message: 'Failed to update user organization pins', - error_code: :invalid_user, + error_code: :invalid_user_organization_pin, details: pin.errors ) end @@ -45,7 +45,7 @@ def execute end AuditService.audit( - :user_updated, + :user_organization_pins_updated, author_id: current_authentication.user.id, entity: user, target: user, diff --git a/docs/graphql/mutation/usersupdateorganizationpins.md b/docs/graphql/mutation/usersupdateorganizationpins.md index 2af08603..362ad73a 100644 --- a/docs/graphql/mutation/usersupdateorganizationpins.md +++ b/docs/graphql/mutation/usersupdateorganizationpins.md @@ -10,7 +10,6 @@ Update pinned organizations for a user. |------|------|-------------| | `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | | `organizationIds` | [`[OrganizationID!]!`](../scalar/organizationid.md) | Ordered list of organization IDs to pin for the user. | -| `userId` | [`UserID!`](../scalar/userid.md) | ID of the user to update. | ## Fields diff --git a/spec/policies/user_organization_pin_policy_spec.rb b/spec/policies/user_organization_pin_policy_spec.rb new file mode 100644 index 00000000..7fed6e6f --- /dev/null +++ b/spec/policies/user_organization_pin_policy_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserOrganizationPinPolicy do + subject { described_class.new(create_authentication(current_user), user_organization_pin) } + + let(:user_organization_pin) { create(:user_organization_pin) } + + context 'when user owns the pin' do + let(:current_user) { user_organization_pin.user } + + it { is_expected.to be_allowed(:read_user_organization_pin) } + end + + context 'when user does not own the pin' do + let(:current_user) { create(:user) } + + it { is_expected.not_to be_allowed(:read_user_organization_pin) } + end + + context 'when user is nil' do + let(:current_user) { nil } + + it { is_expected.not_to be_allowed(:read_user_organization_pin) } + end +end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 9348ca34..7f0194d0 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -7,17 +7,31 @@ let(:user) { create(:user) } - context 'when user is present' do + context 'when user is self' do + let(:current_user) { user } + + it { is_expected.to be_allowed(:read_user) } + it { is_expected.to be_allowed(:update_user_organization_pin) } + end + + context 'when user is someone else' do let(:current_user) { create(:user) } it { is_expected.to be_allowed(:read_user) } - it { is_expected.to be_allowed(:read_user_organization_pin) } + it { is_expected.not_to be_allowed(:update_user_organization_pin) } + end + + context 'when user is admin' do + let(:current_user) { create(:user, :admin) } + + it { is_expected.to be_allowed(:read_user) } + it { is_expected.not_to be_allowed(:update_user_organization_pin) } end context 'when user is nil' do let(:current_user) { nil } it { is_expected.not_to be_allowed(:read_user) } - it { is_expected.not_to be_allowed(:read_user_organization_pin) } + it { is_expected.not_to be_allowed(:update_user_organization_pin) } end end diff --git a/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb b/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb index a0eb3734..51954333 100644 --- a/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb +++ b/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb @@ -22,33 +22,34 @@ end let(:current_user) { create(:user) } - let(:target_user) { current_user } let(:organization_a) { create(:organization) } let(:organization_b) { create(:organization) } let(:input) do { - userId: target_user.to_global_id.to_s, - organizationIds: [organization_a.to_global_id.to_s, organization_b.to_global_id.to_s], + organizationIds: [organization_b.to_global_id.to_s, organization_a.to_global_id.to_s], } end let(:variables) { { input: input } } before do + [organization_a, organization_b].each do |organization| + create(:namespace_member, namespace: organization.ensure_namespace, user: current_user) + end + post_graphql mutation, variables: variables, current_user: current_user end it 'updates organization pins in requested order' do - expect(graphql_data_at(:users_update_organization_pins, :user, :id)).to eq(target_user.to_global_id.to_s) + expect(graphql_data_at(:users_update_organization_pins, :user, :id)).to eq(current_user.to_global_id.to_s) - pins = target_user.reload.user_organization_pins.order(:priority) - expect(pins.pluck(:organization_id)).to eq([organization_a.id, organization_b.id]) + pins = current_user.reload.user_organization_pins + expect(pins.pluck(:organization_id)).to eq([organization_b.id, organization_a.id]) expect(pins.pluck(:priority)).to eq([0, 1]) end context 'when one organization id is invalid' do let(:input) do { - userId: target_user.to_global_id.to_s, organizationIds: [organization_a.to_global_id.to_s, 'gid://sagittarius/Organization/999999'], } end diff --git a/spec/services/namespaces/members/delete_service_spec.rb b/spec/services/namespaces/members/delete_service_spec.rb index 4771ebc0..4483a31e 100644 --- a/spec/services/namespaces/members/delete_service_spec.rb +++ b/spec/services/namespaces/members/delete_service_spec.rb @@ -92,6 +92,9 @@ context 'when user is a member' do let(:current_user) { create(:user) } + let!(:organization_pin) do + create(:user_organization_pin, user: namespace_member.user, organization: namespace.parent) + end before do create(:namespace_member, namespace: namespace, user: current_user) @@ -101,6 +104,12 @@ it { is_expected.to be_success } it { expect { service_response }.to change { NamespaceMember.count }.by(-1) } + it do + expect { service_response } + .to change { UserOrganizationPin.exists?(organization_pin.id) } + .from(true).to(false) + end + it do expect { service_response }.to create_audit_event( :namespace_member_deleted, diff --git a/spec/services/users/update_organization_pins_service_spec.rb b/spec/services/users/update_organization_pins_service_spec.rb index ce8ab8ee..45f4ec38 100644 --- a/spec/services/users/update_organization_pins_service_spec.rb +++ b/spec/services/users/update_organization_pins_service_spec.rb @@ -4,15 +4,21 @@ RSpec.describe Users::UpdateOrganizationPinsService do subject(:service_response) do - described_class.new(create_authentication(current_user), user, organization_ids).execute + described_class.new(create_authentication(current_user), organization_ids).execute end let(:user) { create(:user) } let(:organization_a) { create(:organization) } let(:organization_b) { create(:organization) } - let(:organization_ids) { [organization_a.id, organization_b.id] } + let(:organization_ids) { [organization_b.id, organization_a.id] } - context 'when current user cannot update target user' do + before do + [organization_a, organization_b].each do |organization| + create(:namespace_member, namespace: organization.ensure_namespace, user: user) + end + end + + context 'when current user is missing' do let(:current_user) { nil } it { is_expected.to be_error } @@ -41,18 +47,36 @@ it 'replaces pins in given order with priorities' do service_response - pins = user.reload.user_organization_pins.order(:priority) + pins = user.reload.user_organization_pins expect(pins.pluck(:organization_id)).to eq(organization_ids) expect(pins.pluck(:priority)).to eq([0, 1]) end it 'creates an audit event' do expect { service_response }.to create_audit_event( - :user_updated, + :user_organization_pins_updated, author_id: current_user.id, entity_type: 'User', target_type: 'User' ) end end + + context 'when a pin cannot be saved' do + let(:current_user) { user } + let(:pins_association) { user.user_organization_pins } + + before do + allow(user).to receive(:user_organization_pins).and_return(pins_association) + allow(pins_association).to receive(:build).and_wrap_original do |original_method, *args| + original_method.call(*args).tap do |pin| + allow(pin).to receive(:save).and_return(false) + end + end + end + + it { is_expected.to be_error } + it { expect(service_response.payload[:error_code]).to eq(:invalid_user_organization_pin) } + it { expect { service_response }.not_to create_audit_event } + end end From ac5669432544bd1e1323bd0c32d566f1a35edf7a Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 11 May 2026 17:43:28 +0200 Subject: [PATCH 9/9] docs: regenerate graphql docs --- app/graphql/types/user_type.rb | 4 ---- docs/graphql/enum/errorcodeenum.md | 1 + docs/graphql/object/userorganizationpin.md | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 6d53d47c..898bc8d1 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -84,9 +84,5 @@ def mfa_status def organization_pins object.user_organization_pins.order(priority: :asc) end - - def pinned_organizations - object.pinned_organizations - end end end diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index a471a6d5..4fa34de9 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -52,6 +52,7 @@ Represents the available error responses | `INVALID_TOTP_SECRET` | The TOTP secret is invalid or cannot be verified | | `INVALID_USER` | The user is invalid because of active model errors | | `INVALID_USER_IDENTITY` | The user identity is invalid because of active model errors | +| `INVALID_USER_ORGANIZATION_PIN` | The user organization pin is invalid because of active model errors | | `INVALID_USER_SESSION` | The user session is invalid because of active model errors | | `INVALID_VERIFICATION_CODE` | Invalid verification code provided | | `IS_PRIMARY_RUNTIME` | This runtime is the primary runtime of a project | diff --git a/docs/graphql/object/userorganizationpin.md b/docs/graphql/object/userorganizationpin.md index f7c1a0f9..3a9954b8 100644 --- a/docs/graphql/object/userorganizationpin.md +++ b/docs/graphql/object/userorganizationpin.md @@ -10,7 +10,7 @@ Represents a pinned organization of a user |------|------|-------------| | `createdAt` | [`Time!`](../scalar/time.md) | Time when this UserOrganizationPin was created | | `id` | [`UserOrganizationPinID!`](../scalar/userorganizationpinid.md) | Global ID of this UserOrganizationPin | -| `organization` | [`Organization!`](../object/organization.md) | The pinned organization | +| `organization` | [`Organization`](../object/organization.md) | The pinned organization | | `priority` | [`Int!`](../scalar/int.md) | Ordering priority of the pin | | `updatedAt` | [`Time!`](../scalar/time.md) | Time when this UserOrganizationPin was last updated | | `user` | [`User!`](../object/user.md) | The user owning this pin |