Add Organizations::HardDeletionService and Organizations::HardDeletionWorker for hard-deletion
## What Introduce `Organizations::HardDeletionService` and `Organizations::HardDeletionWorker` to handle the actual hard-deletion of an organization. Organizations must be in the `soft_deleted` state. Follows the same two-class pattern as `Groups::DestroyService` + `GroupDestroyWorker`. ## New files ### `app/services/organizations/hard_deletion_service.rb` - `async_execute` — authorization check, transitions to `deletion_in_progress`, enqueues `HardDeletionWorker` - `execute` — called by the worker; cancels deletion and returns error if unauthorized; calls `unsafe_execute` - `unsafe_execute` — transitions to `deletion_in_progress` if not already there, calls `organization.destroy!`; on any failure calls `soft_delete!` (transition from `deletion_in_progress` to `soft_deleted` need to be added), logs the error, and re-raises for Sidekiq retry ```ruby # frozen_string_literal: true module Organizations class HardDeletionService < BaseService def initialize(organization, current_user:) @organization = organization @current_user = current_user end def async_execute return error(_('Insufficient permissions')) unless authorized? return error(_('Organization needs to be soft-deleted')) unless organization.soft_deleted? organization.transaction do organization.start_deletion!(transition_user: current_user) end Organizations::DestroyWorker.perform_async(organization.id, current_user.id) ServiceResponse.success(payload: { organization: organization }) end def execute return error(_('Insufficient permissions')) unless authorized? return error(_('Organization needs to be soft-deleted')) unless organization.soft_deleted? hard_delete ServiceResponse.success(payload: { organization: organization }) end private attr_reader :organization, :current_user def hard_delete organization.start_deletion!(transition_user: current_user) unless organization.deletion_in_progress? organization.destroy! # TODO: publish Organizations::OrganizationDeletedEvent rescue => e organization.soft_delete! Gitlab::AppLogger.error( message: 'Organization destruction failed', organization_id: organization.id, error: e.message ) raise end def authorized? Ability.allowed?(current_user, :delete_organization, organization) end def error(message) ServiceResponse.error(message: message, payload: { organization: nil }) end end end ``` ### `app/workers/organizations/hard_deletion_worker.rb` - `data_consistency :always` - `sidekiq_options retry: 3` - `idempotent!` with `deduplicate :until_executed, ttl: 2.hours` - Returns early if organization no longer exists - Calls `Organizations::HardDeletionService#execute` ```ruby # frozen_string_literal: true module Organizations class HardDeletionWorker include ApplicationWorker data_consistency :always sidekiq_options retry: 3 include ExceptionBacktrace feature_category :organization idempotent! deduplicate :until_executed, ttl: 2.hours def perform(organization_id, user_id) organization = Organizations::Organization.find_by_id(organization_id) return unless organization user = User.find(user_id) ::Organizations::HardDeletionService.new(organization, current_user: user).execute end end end ``` ## Tests `spec/services/organizations/hard_deletion_service_spec.rb` - `async_execute` returns error for unauthorized user - `async_execute` transitions org to `deletion_in_progress` and enqueues `HardDeletionWorker` - `execute` cancels deletion and returns error for unauthorized user - `execute` hard-deletes the organization record - `execute` transitions to `soft_deleted` via `soft_delete!` and re-raises when an error occurs during destruction `spec/workers/organizations/destroy_worker_spec.rb` - Returns early without error when organization is not found - Calls `HardDeletionService#execute` with the correct organization and user
issue