Skip to content
Draft
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
33 changes: 33 additions & 0 deletions app/graphql/mutations/users/update_organization_pins.rb
Original file line number Diff line number Diff line change
@@ -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 :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)
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
1 change: 1 addition & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
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: 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
16 changes: 16 additions & 0 deletions app/graphql/types/user_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions app/models/user_organization_pin.rb
Original file line number Diff line number Diff line change
@@ -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
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
delegate { subject.user }

rule { can?(:read_user) }.enable :read_user_organization_pin
end
5 changes: 4 additions & 1 deletion app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions app/services/users/update_organization_pins_service.rb
Original file line number Diff line number Diff line change
@@ -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, 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 do |pin|
{ organization_id: pin.organization_id, priority: pin.priority }
end

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
16 changes: 16 additions & 0 deletions db/migrate/20260504153000_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/20260504153000
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
7cf08006d8ab53c1cea495d14f91286debd633d327ea0dee7148ce805d61c32d
33 changes: 33 additions & 0 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
21 changes: 21 additions & 0 deletions docs/graphql/mutation/usersupdateorganizationpins.md
Original file line number Diff line number Diff line change
@@ -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. |
2 changes: 2 additions & 0 deletions docs/graphql/object/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
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 |
| `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
10 changes: 10 additions & 0 deletions spec/factories/users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Loading