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
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
|
|
}
|