package user import ( "context" "encoding/json" "errors" "strings" "time" domain "knowfoolery/backend/services/user-service/internal/domain/user" sharederrors "knowfoolery/backend/shared/domain/errors" sharedtypes "knowfoolery/backend/shared/domain/types" sharedsecurity "knowfoolery/backend/shared/infra/security" ) // Service orchestrates user use-cases. type Service struct { repo domain.Repository } // NewService creates a new user service. func NewService(repo domain.Repository) *Service { return &Service{repo: repo} } // Register creates or returns an existing user (idempotent behavior). func (s *Service) Register(ctx context.Context, in RegisterInput) (*domain.User, error) { zitadelID, email, err := validateIdentity(in) if err != nil { return nil, err } displayName, consentVersion, consentSource, err := sanitizeRegistrationInput(in) if err != nil { return nil, err } existing, err := s.findExistingUser(ctx, zitadelID, email) if err != nil { return nil, err } if existing != nil { return existing, nil } now := time.Now().UTC() created, err := s.repo.Create(ctx, &domain.User{ ZitadelUserID: zitadelID, Email: email, EmailVerified: false, DisplayName: displayName, ConsentVersion: consentVersion, ConsentGivenAt: now, ConsentSource: consentSource, }) if err != nil { return nil, err } return created, nil } // GetProfile returns a user profile by id. func (s *Service) GetProfile(ctx context.Context, id string) (*domain.User, error) { if strings.TrimSpace(id) == "" { return nil, domain.ErrValidationFailed } return s.repo.GetByID(ctx, id) } // UpdateProfile updates mutable profile fields. func (s *Service) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*domain.User, error) { if strings.TrimSpace(id) == "" { return nil, domain.ErrValidationFailed } displayName := sharedsecurity.SanitizePlayerName(in.DisplayName) if displayName == "" { return nil, domain.ErrValidationFailed } consentVersion := strings.TrimSpace(in.ConsentVersion) if consentVersion == "" || len(consentVersion) > 32 { return nil, domain.ErrValidationFailed } consentSource := strings.TrimSpace(in.ConsentSource) if consentSource == "" { consentSource = "web" } if len(consentSource) > 32 { return nil, domain.ErrValidationFailed } return s.repo.UpdateProfile(ctx, id, displayName, domain.ConsentRecord{ Version: consentVersion, GivenAt: time.Now().UTC(), Source: consentSource, }) } // VerifyEmail sets email verified flag. func (s *Service) VerifyEmail(ctx context.Context, id string) (*domain.User, error) { if strings.TrimSpace(id) == "" { return nil, domain.ErrValidationFailed } return s.repo.MarkEmailVerified(ctx, id) } // DeleteUser soft-deletes a user and writes audit. func (s *Service) DeleteUser(ctx context.Context, id, actorUserID string) error { if strings.TrimSpace(id) == "" { return domain.ErrValidationFailed } return s.repo.SoftDelete(ctx, id, actorUserID) } // AdminListUsers lists users with pagination and filters. func (s *Service) AdminListUsers( ctx context.Context, in ListUsersInput, ) ([]*domain.User, int64, sharedtypes.Pagination, error) { pagination := sharedtypes.Pagination{Page: in.Page, PageSize: in.PageSize} pagination.Normalize() items, total, err := s.repo.List(ctx, pagination, domain.ListFilter{ Email: strings.ToLower(strings.TrimSpace(in.Email)), DisplayName: strings.TrimSpace(in.DisplayName), CreatedAfter: in.CreatedAfter, CreatedBefore: in.CreatedBefore, IncludeDeleted: in.IncludeDeleted, }) if err != nil { return nil, 0, pagination, err } return items, total, pagination, nil } // AdminExportUser builds GDPR export payload and writes audit log. func (s *Service) AdminExportUser(ctx context.Context, id, actorUserID string) (*ExportBundle, error) { if strings.TrimSpace(id) == "" { return nil, domain.ErrValidationFailed } u, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } logs, err := s.repo.AuditLogsByUserID(ctx, id) if err != nil { return nil, err } meta := map[string]string{"operation": domain.AuditActionGDPRExport} metaJSON, _ := json.Marshal(meta) _ = s.repo.WriteAuditLog(ctx, domain.AuditLogEntry{ ActorUserID: actorUserID, TargetUserID: id, Action: domain.AuditActionGDPRExport, MetadataJSON: string(metaJSON), }) return &ExportBundle{User: u, AuditLogs: logs}, nil } func isNotFoundError(err error) bool { if err == nil { return false } var domainErr *sharederrors.DomainError if errors.As(err, &domainErr) { return domainErr.Code == sharederrors.CodeUserNotFound } return false } func validateIdentity(in RegisterInput) (string, string, error) { zitadelID := strings.TrimSpace(in.ActorZitadelUserID) email := strings.ToLower(strings.TrimSpace(in.ActorEmail)) if zitadelID == "" || email == "" { return "", "", domain.ErrUnauthorized } if !sharedsecurity.IsValidEmail(email) { return "", "", domain.ErrValidationFailed } return zitadelID, email, nil } func sanitizeRegistrationInput(in RegisterInput) (string, string, string, error) { displayName := sharedsecurity.SanitizePlayerName(in.DisplayName) if displayName == "" { return "", "", "", domain.ErrValidationFailed } consentVersion := strings.TrimSpace(in.ConsentVersion) if consentVersion == "" || len(consentVersion) > 32 { return "", "", "", domain.ErrValidationFailed } consentSource := strings.TrimSpace(in.ConsentSource) if consentSource == "" { consentSource = "web" } if len(consentSource) > 32 { return "", "", "", domain.ErrValidationFailed } return displayName, consentVersion, consentSource, nil } func (s *Service) findExistingUser( ctx context.Context, zitadelID string, email string, ) (*domain.User, error) { existing, err := s.repo.GetByZitadelUserID(ctx, zitadelID) if err == nil && existing != nil { return existing, nil } if !isNotFoundError(err) { return nil, err } existing, err = s.repo.GetByEmail(ctx, email) if err == nil && existing != nil { return existing, nil } if !isNotFoundError(err) { return nil, err } return nil, nil }