diff --git a/app/graphql/mutations/team_roles/create.rb b/app/graphql/mutations/team_roles/create.rb new file mode 100644 index 00000000..a4c44835 --- /dev/null +++ b/app/graphql/mutations/team_roles/create.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module TeamRoles + class Create < BaseMutation + description 'Create a new role in a team.' + + field :team_role, Types::TeamRoleType, description: 'The newly created team role' + + argument :name, String, description: 'The name for the new role' + argument :team_id, Types::GlobalIdType[::Team], description: 'The id of the team which this role will belong to' + + def resolve(team_id:, **params) + team = SagittariusSchema.object_from_id(team_id) + + return { team_role: nil, errors: [create_message_error('Invalid team')] } if team.nil? + + ::TeamRoles::CreateService.new(current_user, team, params).execute.to_mutation_response(success_key: :team_role) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index fb6fcd69..3356eec7 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -7,6 +7,7 @@ class MutationType < Types::BaseObject include Sagittarius::Graphql::MountMutation mount_mutation Mutations::ApplicationSettings::Update + mount_mutation Mutations::TeamRoles::Create mount_mutation Mutations::Teams::Create mount_mutation Mutations::Users::Login mount_mutation Mutations::Users::Logout diff --git a/app/graphql/types/team_role_type.rb b/app/graphql/types/team_role_type.rb new file mode 100644 index 00000000..d37f9c44 --- /dev/null +++ b/app/graphql/types/team_role_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + class TeamRoleType < BaseObject + description 'Represents a team role.' + + authorize :read_team_role + + field :name, String, null: false, description: 'The name of this role' + field :team, Types::TeamType, null: false, description: 'The team where this role belongs to' + + id_field ::TeamRole + timestamps + end +end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 2b8d6c3e..ca277034 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -6,6 +6,7 @@ class AuditEvent < ApplicationRecord user_logged_in: 2, team_created: 3, application_setting_updated: 4, + team_role_created: 5, }.with_indifferent_access enum :action_type, ACTION_TYPES, prefix: :action diff --git a/app/models/team.rb b/app/models/team.rb index c8721346..9f694130 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -9,6 +9,8 @@ class Team < ApplicationRecord has_many :team_members, inverse_of: :team has_many :users, through: :team_members, inverse_of: :teams + has_many :roles, class_name: 'TeamRole', inverse_of: :team + def member?(user) return false if user.nil? diff --git a/app/models/team_role.rb b/app/models/team_role.rb new file mode 100644 index 00000000..f9b87c97 --- /dev/null +++ b/app/models/team_role.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TeamRole < ApplicationRecord + belongs_to :team, inverse_of: :roles + + validates :name, presence: true, + length: { minimum: 3, maximum: 50 }, + allow_blank: false, + uniqueness: { case_sensitive: false, scope: :team_id } +end diff --git a/app/policies/team_policy.rb b/app/policies/team_policy.rb index 38d80587..9d159179 100644 --- a/app/policies/team_policy.rb +++ b/app/policies/team_policy.rb @@ -6,5 +6,7 @@ class TeamPolicy < BasePolicy rule { is_member }.policy do enable :read_team enable :read_team_member + enable :create_team_role + enable :read_team_role end end diff --git a/app/policies/team_role_policy.rb b/app/policies/team_role_policy.rb new file mode 100644 index 00000000..2a35c0d7 --- /dev/null +++ b/app/policies/team_role_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class TeamRolePolicy < BasePolicy + delegate { @subject.team } +end diff --git a/app/services/team_roles/create_service.rb b/app/services/team_roles/create_service.rb new file mode 100644 index 00000000..550b8678 --- /dev/null +++ b/app/services/team_roles/create_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module TeamRoles + class CreateService + include Sagittarius::Database::Transactional + + attr_reader :current_user, :team, :params + + def initialize(current_user, team, params) + @current_user = current_user + @team = team + @params = params + end + + def execute + unless Ability.allowed?(current_user, :create_team_role, team) + return ServiceResponse.error(message: 'Missing permissions', payload: :missing_permission) + end + + transactional do + team_role = TeamRole.create(team: team, **params) + + unless team_role.persisted? + return ServiceResponse.error(message: 'Failed to save team role', payload: team_role.errors) + end + + AuditService.audit( + :team_role_created, + author_id: current_user.id, + entity: team_role, + details: { name: params[:name] }, + target: team + ) + + ServiceResponse.success(message: 'Team role created', payload: team_role) + end + end + end +end diff --git a/db/migrate/20240105213134_create_team_roles.rb b/db/migrate/20240105213134_create_team_roles.rb new file mode 100644 index 00000000..5bccafe6 --- /dev/null +++ b/db/migrate/20240105213134_create_team_roles.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateTeamRoles < Sagittarius::Database::Migration[1.0] + def change + create_table :team_roles do |t| + t.references :team, null: false, foreign_key: true + t.text :name, null: false + + t.index '"team_id", LOWER("name")', unique: true + + t.timestamps_with_timezone + end + end +end diff --git a/db/schema_migrations/20240105213134 b/db/schema_migrations/20240105213134 new file mode 100644 index 00000000..5b97dd35 --- /dev/null +++ b/db/schema_migrations/20240105213134 @@ -0,0 +1 @@ +cf4f81ad02f8203b10cd8c703bf545f11e59651685383ecdb39923d199b87f07 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index e9e76216..ee7e8ec6 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -66,6 +66,23 @@ CREATE SEQUENCE team_members_id_seq ALTER SEQUENCE team_members_id_seq OWNED BY team_members.id; +CREATE TABLE team_roles ( + id bigint NOT NULL, + team_id bigint NOT NULL, + name text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE team_roles_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE team_roles_id_seq OWNED BY team_roles.id; + CREATE TABLE teams ( id bigint NOT NULL, name text NOT NULL, @@ -132,6 +149,8 @@ ALTER TABLE ONLY audit_events ALTER COLUMN id SET DEFAULT nextval('audit_events_ ALTER TABLE ONLY team_members ALTER COLUMN id SET DEFAULT nextval('team_members_id_seq'::regclass); +ALTER TABLE ONLY team_roles ALTER COLUMN id SET DEFAULT nextval('team_roles_id_seq'::regclass); + ALTER TABLE ONLY teams ALTER COLUMN id SET DEFAULT nextval('teams_id_seq'::regclass); ALTER TABLE ONLY user_sessions ALTER COLUMN id SET DEFAULT nextval('user_sessions_id_seq'::regclass); @@ -153,6 +172,9 @@ ALTER TABLE ONLY schema_migrations ALTER TABLE ONLY team_members ADD CONSTRAINT team_members_pkey PRIMARY KEY (id); +ALTER TABLE ONLY team_roles + ADD CONSTRAINT team_roles_pkey PRIMARY KEY (id); + ALTER TABLE ONLY teams ADD CONSTRAINT teams_pkey PRIMARY KEY (id); @@ -172,6 +194,10 @@ CREATE UNIQUE INDEX index_team_members_on_team_id_and_user_id ON team_members US CREATE INDEX index_team_members_on_user_id ON team_members USING btree (user_id); +CREATE INDEX index_team_roles_on_team_id ON team_roles USING btree (team_id); + +CREATE UNIQUE INDEX "index_team_roles_on_team_id_LOWER_name" ON team_roles USING btree (team_id, lower(name)); + CREATE UNIQUE INDEX "index_teams_on_LOWER_name" ON teams USING btree (lower(name)); CREATE UNIQUE INDEX index_user_sessions_on_token ON user_sessions USING btree (token); @@ -191,5 +217,8 @@ ALTER TABLE ONLY team_members ALTER TABLE ONLY user_sessions ADD CONSTRAINT fk_rails_9fa262d742 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY team_roles + ADD CONSTRAINT fk_rails_af974e1e44 FOREIGN KEY (team_id) REFERENCES teams(id); + ALTER TABLE ONLY audit_events ADD CONSTRAINT fk_rails_f64374fc56 FOREIGN KEY (author_id) REFERENCES users(id); diff --git a/docs/content/graphql/mutation/teamrolescreate.md b/docs/content/graphql/mutation/teamrolescreate.md new file mode 100644 index 00000000..05cd5730 --- /dev/null +++ b/docs/content/graphql/mutation/teamrolescreate.md @@ -0,0 +1,21 @@ +--- +title: teamRolesCreate +--- + +Create a new role in a team. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `name` | [`String!`](../scalar/string.md) | The name for the new role | +| `teamId` | [`TeamID!`](../scalar/teamid.md) | The id of the team which this role will belong to | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../union/error.md) | Errors encountered during execution of the mutation. | +| `teamRole` | [`TeamRole`](../object/teamrole.md) | The newly created team role | diff --git a/docs/content/graphql/object/teamrole.md b/docs/content/graphql/object/teamrole.md new file mode 100644 index 00000000..846dde21 --- /dev/null +++ b/docs/content/graphql/object/teamrole.md @@ -0,0 +1,16 @@ +--- +title: TeamRole +--- + +Represents a team role. + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `createdAt` | [`Time!`](../scalar/time.md) | Time when this TeamRole was created | +| `id` | [`TeamRoleID!`](../scalar/teamroleid.md) | Global ID of this TeamRole | +| `name` | [`String!`](../scalar/string.md) | The name of this role | +| `team` | [`Team!`](../object/team.md) | The team where this role belongs to | +| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this TeamRole was last updated | + diff --git a/docs/content/graphql/scalar/teamroleid.md b/docs/content/graphql/scalar/teamroleid.md new file mode 100644 index 00000000..295f44b4 --- /dev/null +++ b/docs/content/graphql/scalar/teamroleid.md @@ -0,0 +1,5 @@ +--- +title: TeamRoleID +--- + +A unique identifier for all TeamRole entities of the application diff --git a/spec/factories/team_roles.rb b/spec/factories/team_roles.rb new file mode 100644 index 00000000..103ce635 --- /dev/null +++ b/spec/factories/team_roles.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + sequence(:role_name) { |n| "role#{n}" } + + factory :team_role do + team + name { generate(:role_name) } + end +end diff --git a/spec/graphql/mutations/team_roles/create_spec.rb b/spec/graphql/mutations/team_roles/create_spec.rb new file mode 100644 index 00000000..5923ba20 --- /dev/null +++ b/spec/graphql/mutations/team_roles/create_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::TeamRoles::Create do + it { expect(described_class.graphql_name).to eq('TeamRolesCreate') } +end diff --git a/spec/graphql/types/team_role_type_spec.rb b/spec/graphql/types/team_role_type_spec.rb new file mode 100644 index 00000000..d900160a --- /dev/null +++ b/spec/graphql/types/team_role_type_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SagittariusSchema.types['TeamRole'] do + let(:fields) do + %w[ + id + team + name + createdAt + updatedAt + ] + end + + it { expect(described_class.graphql_name).to eq('TeamRole') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_team_role) } +end diff --git a/spec/models/team_role_spec.rb b/spec/models/team_role_spec.rb new file mode 100644 index 00000000..c83cba87 --- /dev/null +++ b/spec/models/team_role_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamRole do + subject { create(:team_role) } + + describe 'associations' do + it { is_expected.to belong_to(:team).required } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).case_insensitive.scoped_to(:team_id) } + it { is_expected.to validate_length_of(:name).is_at_most(50) } + end +end diff --git a/spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb b/spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb new file mode 100644 index 00000000..a54e54a9 --- /dev/null +++ b/spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'teamRolesCreate Mutation' do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } + + let(:mutation) do + <<~QUERY + mutation($input: TeamRolesCreateInput!) { + teamRolesCreate(input: $input) { + #{error_query} + teamRole { + id + name + team { + id + } + } + } + } + QUERY + end + + let(:team) { create(:team) } + let(:input) do + name = generate(:role_name) + + { + teamId: team.to_global_id.to_s, + name: name, + } + end + + let(:variables) { { input: input } } + let(:current_user) { create(:user) } + + context 'when user is a member of the team' do + before do + create(:team_member, team: team, user: current_user) + end + + it 'creates team role', :aggregate_failures do + mutate! + + expect(graphql_data_at(:team_roles_create, :team_role, :id)).to be_present + + team_role = SagittariusSchema.object_from_id(graphql_data_at(:team_roles_create, :team_role, :id)) + + expect(team_role.name).to eq(input[:name]) + expect(team_role.team).to eq(team) + + is_expected.to create_audit_event( + :team_role_created, + author_id: current_user.id, + entity_id: team_role.id, + entity_type: 'TeamRole', + details: { name: input[:name] }, + target_id: team.id, + target_type: 'Team' + ) + end + + context 'when team role name is taken' do + let(:team_role) { create(:team_role, team: team) } + let(:input) { { teamId: team.to_global_id.to_s, name: team_role.name } } + + it 'returns an error', :aggregate_failures do + mutate! + + expect(graphql_data_at(:team_roles_create, :team_role)).to be_nil + expect(graphql_data_at(:team_roles_create, :errors)).to include({ 'attribute' => 'name', 'type' => 'taken' }) + end + end + + context 'when team role name is taken in another team' do + let(:other_team) { create(:team).tap { |t| create(:team_role, team: t, name: input[:name]) } } + + it 'creates team role', :aggregate_failures do + mutate! + + expect(graphql_data_at(:team_roles_create, :team_role, :id)).to be_present + + team_role = SagittariusSchema.object_from_id(graphql_data_at(:team_roles_create, :team_role, :id)) + + expect(team_role.name).to eq(input[:name]) + expect(team_role.team).to eq(team) + + is_expected.to create_audit_event( + :team_role_created, + author_id: current_user.id, + entity_id: team_role.id, + entity_type: 'TeamRole', + details: { name: input[:name] }, + target_id: team.id, + target_type: 'Team' + ) + end + end + end + + context 'when user is not a member of the team' do + it 'returns an error', :aggregate_failures do + mutate! + + expect(graphql_data_at(:team_roles_create, :team_role)).to be_nil + expect(graphql_data_at(:team_roles_create, :errors)).to include({ 'message' => 'missing_permission' }) + end + end +end diff --git a/spec/services/team_roles/create_service_spec.rb b/spec/services/team_roles/create_service_spec.rb new file mode 100644 index 00000000..f27c894e --- /dev/null +++ b/spec/services/team_roles/create_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamRoles::CreateService do + subject(:service_response) { described_class.new(current_user, team, params).execute } + + let(:team) { create(:team) } + let(:role_name) { generate(:role_name) } + let(:params) { { name: role_name } } + + context 'when user is nil' do + let(:current_user) { nil } + + it { is_expected.not_to be_success } + it { expect(service_response.payload).to eq(:missing_permission) } + it { expect { service_response }.not_to change { TeamRole.count } } + + it do + expect { service_response }.not_to create_audit_event(:team_role_created) + end + end + + context 'when user is not a member' do + let(:current_user) { create(:user) } + + it { is_expected.not_to be_success } + it { expect(service_response.payload).to eq(:missing_permission) } + it { expect { service_response }.not_to change { TeamRole.count } } + + it do + expect { service_response }.not_to create_audit_event(:team_role_created) + end + end + + context 'when user is a member' do + let(:current_user) { create(:user) } + + before do + create(:team_member, team: team, user: current_user) + end + + it { is_expected.to be_success } + it { expect(service_response.payload.team).to eq(team) } + it { expect(service_response.payload.name).to eq(role_name) } + it { expect { service_response }.to change { TeamRole.count }.by(1) } + + it do + expect { service_response }.to create_audit_event( + :team_role_created, + author_id: current_user.id, + entity_type: 'TeamRole', + details: { name: role_name }, + target_id: team.id, + target_type: 'Team' + ) + end + end +end