Files
galaxy-game/user/internal/service/accountdeletion/service.go
T
2026-04-25 23:20:55 +02:00

244 lines
7.0 KiB
Go

// 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(),
)
}
}