// Package accountdeletion implements the trusted `DeleteUser` soft-delete // command owned by User Service. package accountdeletion import ( "context" "errors" "fmt" "log/slog" "strings" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" "galaxy/user/internal/telemetry" ) const adminInternalAPISource = common.Source("admin_internal_api") // Input stores one trusted `DeleteUser` command request. type Input struct { // UserID identifies the regular-user account to soft-delete. UserID string // ReasonCode stores the machine-readable mutation reason. ReasonCode string // Actor stores the audit actor metadata attached to the mutation. Actor ActorInput } // ActorInput stores one transport-facing audit actor payload. type ActorInput struct { // Type stores the machine-readable actor type. Type string // ID stores the optional stable actor identifier. ID string } // Result stores one trusted `DeleteUser` command outcome. type Result struct { // UserID identifies the soft-deleted account. UserID string `json:"user_id"` // DeletedAt stores the committed soft-delete timestamp. DeletedAt time.Time `json:"deleted_at"` } // Service executes the explicit trusted `DeleteUser` soft-delete command. type Service struct { accounts ports.UserAccountStore clock ports.Clock lifecyclePublisher ports.UserLifecyclePublisher logger *slog.Logger telemetry *telemetry.Runtime } // NewService constructs one `DeleteUser` use case without optional // observability hooks. func NewService( accounts ports.UserAccountStore, clock ports.Clock, lifecyclePublisher ports.UserLifecyclePublisher, ) (*Service, error) { return NewServiceWithObservability(accounts, clock, lifecyclePublisher, nil, nil) } // NewServiceWithObservability constructs one `DeleteUser` use case with // optional observability hooks. func NewServiceWithObservability( accounts ports.UserAccountStore, clock ports.Clock, lifecyclePublisher ports.UserLifecyclePublisher, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, ) (*Service, error) { switch { case accounts == nil: return nil, fmt.Errorf("account deletion service: user account store must not be nil") case clock == nil: return nil, fmt.Errorf("account deletion service: clock must not be nil") case lifecyclePublisher == nil: return nil, fmt.Errorf("account deletion service: lifecycle publisher must not be nil") default: return &Service{ accounts: accounts, clock: clock, lifecyclePublisher: lifecyclePublisher, logger: logger, telemetry: telemetryRuntime, }, nil } } // Execute soft-deletes the account identified by input.UserID. The command is // idempotent per `user_id`: calling it after the account is already // soft-deleted returns `subject_not_found` and does not re-publish the // lifecycle event. func (service *Service) Execute(ctx context.Context, input Input) (result Result, err error) { outcome := shared.ErrorCodeInternalError userIDString := strings.TrimSpace(input.UserID) reasonCodeValue := strings.TrimSpace(input.ReasonCode) actorTypeValue := strings.TrimSpace(input.Actor.Type) actorIDValue := strings.TrimSpace(input.Actor.ID) defer func() { if service.telemetry != nil { service.telemetry.RecordUserLifecycleMutation(ctx, "delete", outcome) } shared.LogServiceOutcome(service.logger, ctx, "delete user completed", err, "use_case", "delete_user", "command", "delete", "outcome", outcome, "user_id", userIDString, "source", adminInternalAPISource.String(), "reason_code", reasonCodeValue, "actor_type", actorTypeValue, "actor_id", actorIDValue, ) }() if ctx == nil { outcome = shared.ErrorCodeInvalidRequest return Result{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { outcome = shared.MetricOutcome(err) return Result{}, err } userIDString = userID.String() reasonCode, err := shared.ParseReasonCode(input.ReasonCode) if err != nil { outcome = shared.MetricOutcome(err) return Result{}, err } reasonCodeValue = reasonCode.String() actor, err := parseActor(input.Actor) if err != nil { outcome = shared.MetricOutcome(err) return Result{}, err } actorTypeValue = actor.Type.String() actorIDValue = actor.ID.String() record, err := service.accounts.GetByUserID(ctx, userID) switch { case err == nil: case errors.Is(err, ports.ErrNotFound): outcome = shared.ErrorCodeSubjectNotFound return Result{}, shared.SubjectNotFound() default: outcome = shared.ErrorCodeServiceUnavailable return Result{}, shared.ServiceUnavailable(err) } if record.IsDeleted() { outcome = shared.ErrorCodeSubjectNotFound return Result{}, shared.SubjectNotFound() } now := service.clock.Now().UTC() record.UpdatedAt = now record.DeletedAt = &now if err := service.accounts.Update(ctx, record); err != nil { switch { case errors.Is(err, ports.ErrNotFound): outcome = shared.ErrorCodeSubjectNotFound return Result{}, shared.SubjectNotFound() case errors.Is(err, ports.ErrConflict): outcome = shared.ErrorCodeConflict return Result{}, shared.Conflict() default: outcome = shared.ErrorCodeServiceUnavailable return Result{}, shared.ServiceUnavailable(err) } } outcome = "success" result = Result{ UserID: userID.String(), DeletedAt: now, } publishDeleted(ctx, service.lifecyclePublisher, service.telemetry, service.logger, userID, now, actor, reasonCode) return result, nil } func parseActor(input ActorInput) (common.ActorRef, error) { ref := common.ActorRef{ Type: common.ActorType(shared.NormalizeString(input.Type)), ID: common.ActorID(shared.NormalizeString(input.ID)), } if err := ref.Validate(); err != nil { if ref.Type.IsZero() { return common.ActorRef{}, shared.InvalidRequest("actor.type must not be empty") } return common.ActorRef{}, shared.InvalidRequest(err.Error()) } return ref, nil } func publishDeleted( ctx context.Context, publisher ports.UserLifecyclePublisher, telemetryRuntime *telemetry.Runtime, logger *slog.Logger, userID common.UserID, occurredAt time.Time, actor common.ActorRef, reasonCode common.ReasonCode, ) { if publisher == nil { return } event := ports.UserLifecycleEvent{ EventType: ports.UserLifecycleDeletedEventType, UserID: userID, OccurredAt: occurredAt, Source: adminInternalAPISource, Actor: actor, ReasonCode: reasonCode, } if err := publisher.PublishUserLifecycleEvent(ctx, event); err != nil { if telemetryRuntime != nil { telemetryRuntime.RecordEventPublicationFailure(ctx, string(ports.UserLifecycleDeletedEventType)) } shared.LogEventPublicationFailure(logger, ctx, string(ports.UserLifecycleDeletedEventType), err, "use_case", "delete_user", "user_id", userID.String(), "source", adminInternalAPISource.String(), "reason_code", reasonCode.String(), "actor_type", actor.Type.String(), "actor_id", actor.ID.String(), ) } }