244 lines
7.0 KiB
Go
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(),
|
|
)
|
|
}
|
|
}
|