You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

230 lines
6.0 KiB
Go

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
}