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