Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/graphql/mutations/users/update_organization_pins.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Mutations
module Users
class UpdateOrganizationPins < BaseMutation
description 'Updates the pinned organizations for the current user, in the given order'

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:)
::Users::UpdateOrganizationPinsService.new(
current_authentication,
organization_ids.map(&:model_id)
).execute.to_mutation_response(success_key: :user)
end
end
end
end
1 change: 1 addition & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class MutationType < Types::BaseObject
mount_mutation Mutations::Users::PasswordReset
mount_mutation Mutations::Users::Register
mount_mutation Mutations::Users::Update
mount_mutation Mutations::Users::UpdateOrganizationPins
end
end

Expand Down
16 changes: 16 additions & 0 deletions app/graphql/types/user_organization_pin_type.rb
Original file line number Diff line number Diff line change
@@ -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, lower is higher priority'
field :user, Types::UserType, null: false, description: 'The user owning this pin'

id_field UserOrganizationPin
timestamps
end
end
5 changes: 5 additions & 0 deletions app/graphql/types/user_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ 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, ordered by priority',
method: :user_organization_pins

field :mfa_status, Types::MfaStatusType,
null: true,
description: 'Multi-factor authentication status of this user'
Expand Down
1 change: 1 addition & 0 deletions app/models/audit_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class AuditEvent < ApplicationRecord
user_deleted: 38,
user_created: 39,
project_module_configurations_updated: 40,
user_organization_pins_updated: 41,
}.with_indifferent_access

# rubocop:disable Lint/StructNewOverride
Expand Down
2 changes: 2 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
class Organization < ApplicationRecord
include NamespaceParent

has_many :user_organization_pins, inverse_of: :organization

validates :name, presence: true,
length: { minimum: 3, maximum: 50 },
allow_blank: false,
Expand Down
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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

has_one_attached :avatar

Expand Down
11 changes: 11 additions & 0 deletions app/models/user_organization_pin.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions app/policies/user_organization_pin_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class UserOrganizationPinPolicy < BasePolicy
condition(:pin_owner) { subject.user_id == user&.id }

rule { pin_owner }.enable :read_user_organization_pin
end
1 change: 1 addition & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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
Expand Down
1 change: 1 addition & 0 deletions app/services/error_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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' },
Expand Down
10 changes: 10 additions & 0 deletions app/services/namespaces/members/delete_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def execute
end

check_last_administrator(t)
remove_organization_pin

AuditService.audit(
:namespace_member_deleted,
Expand Down Expand Up @@ -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
51 changes: 51 additions & 0 deletions app/services/users/update_organization_pins_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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 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|
UserOrganizationPin.where(user: user).delete_all

organization_ids.each_with_index do |organization_id, priority|
pin = user.user_organization_pins.create(organization_id: organization_id, priority: priority)
next if pin.persisted?

t.rollback_and_return! ServiceResponse.error(
message: 'Failed to update user organization pins',
error_code: :invalid_user_organization_pin,
details: pin.errors
)
end

AuditService.audit(
:user_organization_pins_updated,
author_id: user.id,
entity: user,
target: user,
details: { organization_ids: organization_ids }
)

ServiceResponse.success(message: 'Updated user organization pins', payload: user)
end
end
end
end
16 changes: 16 additions & 0 deletions db/migrate/20260821120000_create_user_organization_pins.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions db/schema_migrations/20260821120000
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bc9fdd6e7af45b39fd84d5aad219c9bc87d3fa119011c692ba9abe0f62fec50e
33 changes: 33 additions & 0 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,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,
Expand Down Expand Up @@ -1406,6 +1424,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);
Expand Down Expand Up @@ -1599,6 +1619,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);

Expand Down Expand Up @@ -1831,6 +1854,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);
Expand All @@ -1839,6 +1866,9 @@ CREATE UNIQUE INDEX "index_users_on_LOWER_email" ON users USING btree (lower(ema

CREATE UNIQUE INDEX "index_users_on_LOWER_username" ON users USING btree (lower(username));

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;

Expand Down Expand Up @@ -1971,6 +2001,9 @@ ALTER TABLE ONLY namespace_role_project_assignments
ALTER TABLE ONLY flow_types
ADD CONSTRAINT fk_rails_69115ada7f FOREIGN KEY (runtime_module_id) REFERENCES runtime_modules(id) ON DELETE CASCADE;

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;

Expand Down
1 change: 1 addition & 0 deletions docs/graphql/enum/errorcodeenum.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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 |
Expand Down
20 changes: 20 additions & 0 deletions docs/graphql/mutation/usersupdateorganizationpins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: usersUpdateOrganizationPins
---

Updates the pinned organizations for the current user, in the given order

## 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 |
1 change: 1 addition & 0 deletions docs/graphql/object/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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, ordered by 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 |
Expand Down
16 changes: 16 additions & 0 deletions docs/graphql/object/userorganizationpin.md
Original file line number Diff line number Diff line change
@@ -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, lower is higher priority |
| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this UserOrganizationPin was last updated |
| `user` | [`User!`](../object/user.md) | The user owning this pin |
5 changes: 5 additions & 0 deletions docs/graphql/scalar/userorganizationpinid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: UserOrganizationPinID
---

A unique identifier for all UserOrganizationPin entities of the application
11 changes: 11 additions & 0 deletions spec/factories/user_organization_pins.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions spec/graphql/mutations/users/update_organization_pins_spec.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions spec/graphql/types/user_organization_pin_type_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading