slack5-1770606136/services/preferences-api/internal/service/preference.go
rdev-worker a0ff64af5e
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /implement-feature user-preferences
2026-02-09 03:30:01 +00:00

134 lines
3.4 KiB
Go

package service
import (
"context"
"fmt"
"time"
"golang.org/x/text/language"
"git.threesix.ai/jordan/slack5-1770606136/pkg/logging"
"git.threesix.ai/jordan/slack5-1770606136/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770606136/services/preferences-api/internal/port"
)
// ValidationError carries per-field validation details.
// It wraps domain.ErrInvalidPreferenceValue for errors.Is() matching.
type ValidationError struct {
Details map[string]string
}
func (e *ValidationError) Error() string { return "invalid preference values" }
func (e *ValidationError) Unwrap() error { return domain.ErrInvalidPreferenceValue }
// validThemes is the set of allowed values for the "theme" preference.
var validThemes = map[string]bool{
"light": true,
"dark": true,
"system": true,
}
// PreferenceService handles preference-related business logic.
type PreferenceService struct {
repo port.PreferenceRepository
logger *logging.Logger
}
// NewPreferenceService creates a new preference service.
func NewPreferenceService(repo port.PreferenceRepository, logger *logging.Logger) *PreferenceService {
return &PreferenceService{
repo: repo,
logger: logger.WithService("PreferenceService"),
}
}
// Get returns preferences for a user.
// Returns nil when no preferences exist.
func (s *PreferenceService) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) {
return s.repo.Get(ctx, userID)
}
// UpsertInput contains the data needed to upsert preferences.
type UpsertInput struct {
UserID string
Preferences map[string]any
}
// Upsert validates and stores preferences for a user.
// Known keys are validated; unknown keys are accepted as-is.
// Incoming keys are merged on top of existing preferences.
func (s *PreferenceService) Upsert(ctx context.Context, input UpsertInput) (*domain.UserPreferences, error) {
// Validate known preference keys
if err := validatePreferences(input.Preferences); err != nil {
return nil, err
}
// Fetch existing preferences for merge
existing, err := s.repo.Get(ctx, input.UserID)
if err != nil {
return nil, err
}
merged := make(map[string]any)
if existing != nil {
for k, v := range existing.Preferences {
merged[k] = v
}
}
for k, v := range input.Preferences {
merged[k] = v
}
now := time.Now().UTC()
prefs := &domain.UserPreferences{
UserID: input.UserID,
Preferences: merged,
UpdatedAt: now,
}
if existing != nil {
prefs.CreatedAt = existing.CreatedAt
} else {
prefs.CreatedAt = now
}
if err := s.repo.Upsert(ctx, prefs); err != nil {
return nil, err
}
s.logger.Info("preferences upserted", "user_id", input.UserID)
return prefs, nil
}
// validatePreferences checks known preference keys against their allowed values.
func validatePreferences(prefs map[string]any) error {
details := make(map[string]string)
for key, value := range prefs {
switch key {
case "theme":
s, ok := value.(string)
if !ok || !validThemes[s] {
details[key] = "must be one of: light, dark, system"
}
case "language":
s, ok := value.(string)
if !ok {
details[key] = "must be a valid BCP-47 language tag"
continue
}
if _, err := language.Parse(s); err != nil {
details[key] = fmt.Sprintf("must be a valid BCP-47 language tag")
}
case "notifications_enabled":
if _, ok := value.(bool); !ok {
details[key] = "must be a boolean"
}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}