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..13b18c0f --- /dev/null +++ b/app/graphql/mutations/users/update_organization_pins.rb @@ -0,0 +1,28 @@ +# 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 :organization_ids, + [Types::GlobalIdType[::Organization]], + required: true, + description: 'Ordered list of organization IDs to pin for the user.' + + 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')] } + end + + ::Users::UpdateOrganizationPinsService.new( + current_authentication, + 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..8cbc8fb7 --- /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: 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' + + id_field UserOrganizationPin + timestamps + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index dce975f2..898bc8d1 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -42,6 +42,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' @@ -72,5 +80,9 @@ def mfa_status backup_codes_count: object.backup_codes.size, } end + + def organization_pins + object.user_organization_pins.order(priority: :asc) + end end 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/models/organization.rb b/app/models/organization.rb index 90c15354..59aaaae2 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 + 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..ba756e5c 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 + 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..6a267dbf --- /dev/null +++ b/app/models/user_organization_pin.rb @@ -0,0 +1,11 @@ +# 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 }, + uniqueness: { scope: :user_id } + validates :organization_id, 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..bb7033cb --- /dev/null +++ b/app/policies/user_organization_pin_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UserOrganizationPinPolicy < BasePolicy + delegate { subject.user } + + 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 44ab5bdd..68861ebe 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 new file mode 100644 index 00000000..27e650f7 --- /dev/null +++ b/app/services/users/update_organization_pins_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Users + class UpdateOrganizationPinsService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :user, :organization_ids + + def initialize(current_authentication, organization_ids) + @current_authentication = current_authentication + @user = current_authentication.user + @organization_ids = organization_ids.uniq + end + + def execute + unless user && Ability.allowed?(current_authentication, :update_user_organization_pin, user) + return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) + end + + 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 + + transactional do |t| + old_pins = user.user_organization_pins.map do |pin| + { organization_id: pin.organization_id, priority: pin.priority } + end + + 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) + next if pin.save + + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to update user organization pins', + error_code: :invalid_user_organization_pin, + 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_organization_pins_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 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 b5ecde6a..d9e0861a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -831,6 +831,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, @@ -956,6 +974,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); @@ -1098,6 +1118,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); @@ -1256,6 +1279,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); @@ -1266,6 +1293,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; @@ -1341,6 +1371,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; 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/mutation/usersupdateorganizationpins.md b/docs/graphql/mutation/usersupdateorganizationpins.md new file mode 100644 index 00000000..362ad73a --- /dev/null +++ b/docs/graphql/mutation/usersupdateorganizationpins.md @@ -0,0 +1,20 @@ +--- +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. | + +## 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 0b8f27af..eb096fcc 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 | | `readme` | [`String`](../scalar/string.md) | Readme of the user | | `sessions` | [`UserSessionConnection!`](../object/usersessionconnection.md) | Sessions of this user | | `updatedAt` | [`Time!`](../scalar/time.md) | Time when this User was last updated | diff --git a/docs/graphql/object/userorganizationpin.md b/docs/graphql/object/userorganizationpin.md new file mode 100644 index 00000000..3a9954b8 --- /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 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 e27bba55..97e31f9c 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -18,6 +18,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_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 c3a33169..7f0194d0 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -7,15 +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.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(: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 new file mode 100644 index 00000000..51954333 --- /dev/null +++ b/spec/requests/graphql/mutation/users/update_organization_pins_spec.rb @@ -0,0 +1,63 @@ +# 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(:organization_a) { create(:organization) } + let(:organization_b) { create(:organization) } + let(:input) do + { + 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(current_user.to_global_id.to_s) + + 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 + { + 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/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 new file mode 100644 index 00000000..45f4ec38 --- /dev/null +++ b/spec/services/users/update_organization_pins_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::UpdateOrganizationPinsService do + subject(:service_response) do + 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_b.id, organization_a.id] } + + 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 } + 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 + 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_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