From 5ddecd5bef9b6ed2cc36119b8e75f69505e28889 Mon Sep 17 00:00:00 2001 From: Raphael Date: Tue, 15 Sep 2026 10:35:48 +0200 Subject: [PATCH] feat: implemented set active subscription --- app/models/audit_event.rb | 1 + app/policies/user_policy.rb | 2 + app/services/error_code.rb | 1 + docs/graphql/enum/errorcodeenum.md | 1 + .../mutation/userssetactivesubscription.md | 21 ++++ .../app/graphql/cloud/types/mutation_type.rb | 1 + .../users/set_active_subscription.rb | 26 +++++ .../cloud/app/policies/cloud/base_policy.rb | 1 + .../cloud/app/policies/cloud/user_policy.rb | 15 +++ .../users/set_active_subscription_service.rb | 66 +++++++++++++ .../users/set_active_subscription_spec.rb | 7 ++ .../spec/policies/cloud/user_policy_spec.rb | 35 +++++++ .../set_active_subscription_service_spec.rb | 98 +++++++++++++++++++ 13 files changed, 275 insertions(+) create mode 100644 docs/graphql/mutation/userssetactivesubscription.md create mode 100644 extensions/cloud/app/graphql/mutations/users/set_active_subscription.rb create mode 100644 extensions/cloud/app/policies/cloud/user_policy.rb create mode 100644 extensions/cloud/app/services/users/set_active_subscription_service.rb create mode 100644 extensions/cloud/spec/graphql/mutations/users/set_active_subscription_spec.rb create mode 100644 extensions/cloud/spec/policies/cloud/user_policy_spec.rb create mode 100644 extensions/cloud/spec/services/users/set_active_subscription_service_spec.rb diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 94082fe7c..2f06b85c9 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -49,6 +49,7 @@ class AuditEvent < ApplicationRecord user_project_pins_updated: 43, guest_user_created: 44, # Cloud-specific guest_profile_completed: 45, # Cloud-specific + user_custom_attribute_updated: 46, # Cloud-specific }.with_indifferent_access # rubocop:disable-next Lint/StructNewOverride diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 56df91bfe..5820c0c0c 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -44,3 +44,5 @@ class UserPolicy < BasePolicy rule { deletion_restriction }.prevent :delete_user end + +UserPolicy.prepend_extensions diff --git a/app/services/error_code.rb b/app/services/error_code.rb index fd173d086..c18637e7c 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -45,6 +45,7 @@ def self.error_codes invalid_setting: { description: 'Invalid setting provided' }, invalid_user: { description: 'The user is invalid because of active model errors' }, invalid_user_namespace_pin: { description: 'The user namespace pin is invalid because of active model errors' }, + invalid_user_custom_attribute: { description: 'The user custom attribute is invalid because of active model errors' }, invalid_user_project_pin: { description: 'The user project 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' }, diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index 7d4f5e488..9f0bd21b4 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -65,6 +65,7 @@ Represents the available error responses | `INVALID_SETTING` | Invalid setting provided | | `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_CUSTOM_ATTRIBUTE` | The user custom attribute is invalid because of active model errors | | `INVALID_USER_IDENTITY` | The user identity is invalid because of active model errors | | `INVALID_USER_NAMESPACE_PIN` | The user namespace pin is invalid because of active model errors | | `INVALID_USER_PROJECT_PIN` | The user project pin is invalid because of active model errors | diff --git a/docs/graphql/mutation/userssetactivesubscription.md b/docs/graphql/mutation/userssetactivesubscription.md new file mode 100644 index 000000000..541745d8f --- /dev/null +++ b/docs/graphql/mutation/userssetactivesubscription.md @@ -0,0 +1,21 @@ +--- +title: usersSetActiveSubscription +--- + +(Cloud only) Set or unset the active subscription status for a user. Used by Crater. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `active` | [`Boolean!`](../scalar/boolean.md) | Whether the user has an active subscription. | +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `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/extensions/cloud/app/graphql/cloud/types/mutation_type.rb b/extensions/cloud/app/graphql/cloud/types/mutation_type.rb index 4ff027bf4..e1fbf7fc9 100644 --- a/extensions/cloud/app/graphql/cloud/types/mutation_type.rb +++ b/extensions/cloud/app/graphql/cloud/types/mutation_type.rb @@ -11,6 +11,7 @@ module MutationType mount_mutation Mutations::Users::CompleteGuestProfile mount_mutation Mutations::Users::CreateCraterToken mount_mutation Mutations::Users::CreateGuestUser + mount_mutation Mutations::Users::SetActiveSubscription end end end diff --git a/extensions/cloud/app/graphql/mutations/users/set_active_subscription.rb b/extensions/cloud/app/graphql/mutations/users/set_active_subscription.rb new file mode 100644 index 000000000..1b323c107 --- /dev/null +++ b/extensions/cloud/app/graphql/mutations/users/set_active_subscription.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Users + class SetActiveSubscription < BaseMutation + description '(Cloud only) Set or unset the active subscription status for a user. Used by Crater.' + + field :user, Types::UserType, null: true, description: 'The updated user.' + + argument :active, Boolean, required: true, description: 'Whether the user has an active subscription.' + argument :user_id, Types::GlobalIdType[::User], required: true, description: 'ID of the user to update.' + + def resolve(user_id:, active:) + user = SagittariusSchema.object_from_id(user_id) + + return { user: nil, errors: [create_error(:user_not_found, 'Invalid user with provided id')] } if user.nil? + + ::Users::SetActiveSubscriptionService.new( + current_authentication, + user, + active: active + ).execute.to_mutation_response(success_key: :user) + end + end + end +end diff --git a/extensions/cloud/app/policies/cloud/base_policy.rb b/extensions/cloud/app/policies/cloud/base_policy.rb index b8bad6333..b5be29d18 100644 --- a/extensions/cloud/app/policies/cloud/base_policy.rb +++ b/extensions/cloud/app/policies/cloud/base_policy.rb @@ -19,6 +19,7 @@ module BasePolicy except :create_license except :delete_license except :create_guest_user + except :update_user_custom_attribute end # Guests never get a session today (they only exist until they complete their profile, diff --git a/extensions/cloud/app/policies/cloud/user_policy.rb b/extensions/cloud/app/policies/cloud/user_policy.rb new file mode 100644 index 000000000..90baff43d --- /dev/null +++ b/extensions/cloud/app/policies/cloud/user_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module CLOUD + module UserPolicy + extend ActiveSupport::Concern + + prepended do + condition(:crater_user) { user&.crater? } + + rule { crater_user }.policy do + enable :update_user_custom_attribute + end + end + end +end diff --git a/extensions/cloud/app/services/users/set_active_subscription_service.rb b/extensions/cloud/app/services/users/set_active_subscription_service.rb new file mode 100644 index 000000000..a0d4f310c --- /dev/null +++ b/extensions/cloud/app/services/users/set_active_subscription_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Users + class SetActiveSubscriptionService + include Sagittarius::Database::Transactional + + ACTIVE_SUBSCRIPTION_KEY = 'active_subscription' + + attr_reader :current_authentication, :user, :active + + def initialize(current_authentication, user, active:) + @current_authentication = current_authentication + @user = user + @active = active + end + + def execute + unless Ability.allowed?(current_authentication, :update_user_custom_attribute, user) + return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) + end + + active ? set_active_subscription : unset_active_subscription + end + + private + + def set_active_subscription + transactional do |t| + attribute = user.user_custom_attributes.find_or_initialize_by(key: ACTIVE_SUBSCRIPTION_KEY) + attribute.value = true + + unless attribute.save + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to set active subscription', + error_code: :invalid_user_custom_attribute, + details: attribute.errors + ) + end + + audit(active: true) + + ServiceResponse.success(message: 'Set active subscription', payload: user) + end + end + + def unset_active_subscription + transactional do |_t| + user.user_custom_attributes.where(key: ACTIVE_SUBSCRIPTION_KEY).destroy_all + + audit(active: false) + + ServiceResponse.success(message: 'Unset active subscription', payload: user) + end + end + + def audit(active:) + AuditService.audit( + :user_custom_attribute_updated, + author_id: current_authentication.user.id, + entity: user, + target: AuditEvent::GLOBAL_TARGET, + details: { key: ACTIVE_SUBSCRIPTION_KEY, active: active } + ) + end + end +end diff --git a/extensions/cloud/spec/graphql/mutations/users/set_active_subscription_spec.rb b/extensions/cloud/spec/graphql/mutations/users/set_active_subscription_spec.rb new file mode 100644 index 000000000..aa9647a4a --- /dev/null +++ b/extensions/cloud/spec/graphql/mutations/users/set_active_subscription_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Users::SetActiveSubscription do + it { expect(described_class.graphql_name).to eq('UsersSetActiveSubscription') } +end diff --git a/extensions/cloud/spec/policies/cloud/user_policy_spec.rb b/extensions/cloud/spec/policies/cloud/user_policy_spec.rb new file mode 100644 index 000000000..f65954dad --- /dev/null +++ b/extensions/cloud/spec/policies/cloud/user_policy_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserPolicy do + it { expect(described_class).to include_module(CLOUD::UserPolicy) } + + describe 'crater authentication' do + subject(:policy) { described_class.new(authentication, target_user) } + + let(:crater_user) { create(:user, :crater) } + let(:target_user) { create(:user) } + + let(:authentication) do + Sagittarius::Authentication.new( + :crater, + CLOUD::ApplicationController::CraterToken.new(user: crater_user) + ) + end + + it { is_expected.to be_allowed(:update_user_custom_attribute) } + it { is_expected.not_to be_allowed(:read_user) } + it { is_expected.not_to be_allowed(:update_user) } + it { is_expected.not_to be_allowed(:delete_user) } + end + + describe 'session authentication' do + subject(:policy) { described_class.new(create_authentication(current_user), target_user) } + + let(:current_user) { create(:user) } + let(:target_user) { create(:user) } + + it { is_expected.not_to be_allowed(:update_user_custom_attribute) } + end +end diff --git a/extensions/cloud/spec/services/users/set_active_subscription_service_spec.rb b/extensions/cloud/spec/services/users/set_active_subscription_service_spec.rb new file mode 100644 index 000000000..c15f57c41 --- /dev/null +++ b/extensions/cloud/spec/services/users/set_active_subscription_service_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::SetActiveSubscriptionService do + subject(:service_response) { described_class.new(authentication, user, active: active).execute } + + let(:user) { create(:user) } + + context 'when the current authentication is not crater' do + let(:authentication) { create_authentication(create(:user)) } + let(:active) { true } + + it { is_expected.to be_error } + it { expect(service_response.payload[:error_code]).to eq(:missing_permission) } + + it 'does not create a custom attribute' do + expect { service_response }.not_to change { user.user_custom_attributes.count } + end + + it { expect { service_response }.not_to create_audit_event } + end + + context 'when the current authentication is crater' do + let(:crater_user) { create(:user, :crater) } + let(:authentication) do + Sagittarius::Authentication.new(:crater, CLOUD::ApplicationController::CraterToken.new(user: crater_user)) + end + + context 'when setting active to true' do + let(:active) { true } + + it { is_expected.to be_success } + + it 'creates the active_subscription custom attribute' do + service_response + + expect(user.user_custom_attributes.find_by(key: 'active_subscription').value).to be(true) + end + + it do + is_expected.to create_audit_event( + :user_custom_attribute_updated, + author_id: crater_user.id, + entity_id: user.id, + entity_type: 'User', + details: { key: 'active_subscription', active: true } + ) + end + + context 'when the attribute already exists' do + before { create(:user_custom_attribute, user: user, key: 'active_subscription', value: 'pending') } + + it 'does not create a duplicate' do + expect { service_response }.not_to change { user.user_custom_attributes.count } + end + + it 'updates the value' do + service_response + + expect(user.user_custom_attributes.find_by(key: 'active_subscription').value).to be(true) + end + end + end + + context 'when setting active to false' do + let(:active) { false } + + context 'when the attribute exists' do + before { create(:user_custom_attribute, user: user, key: 'active_subscription', value: true) } + + it { is_expected.to be_success } + + it 'removes the custom attribute' do + expect { service_response }.to change { user.user_custom_attributes.count }.by(-1) + end + + it do + is_expected.to create_audit_event( + :user_custom_attribute_updated, + author_id: crater_user.id, + entity_id: user.id, + entity_type: 'User', + details: { key: 'active_subscription', active: false } + ) + end + end + + context 'when the attribute does not exist' do + it { is_expected.to be_success } + + it 'does not change the custom attribute count' do + expect { service_response }.not_to change { user.user_custom_attributes.count } + end + end + end + end +end