package handlers import ( "encoding/json" "errors" "fmt" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" "git.threesix.ai/jordan/slate-v3-1770514618/pkg/auth" "git.threesix.ai/jordan/slate-v3-1770514618/pkg/httperror" "git.threesix.ai/jordan/slate-v3-1770514618/pkg/httpresponse" "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/service" ) // Preferences handles HTTP requests for user preferences. type Preferences struct { svc *service.PreferencesService } // NewPreferences creates a new Preferences handler. func NewPreferences(svc *service.PreferencesService) *Preferences { return &Preferences{svc: svc} } // UpdatePreferencesRequest is the request body for updating preferences. type UpdatePreferencesRequest struct { Preferences *PreferencesPayload `json:"preferences" validate:"required"` } // PreferencesPayload represents the preferences object in the request. type PreferencesPayload struct { Theme *string `json:"theme,omitempty"` Language *string `json:"language,omitempty"` Notifications *NotificationSettingsPayload `json:"notifications,omitempty"` } // NotificationSettingsPayload represents notification settings in the request. type NotificationSettingsPayload struct { Email *bool `json:"email,omitempty"` Push *bool `json:"push,omitempty"` Digest *string `json:"digest,omitempty"` } // PreferencesResponse is the response for preferences. type PreferencesResponse struct { UserID string `json:"user_id"` Preferences PreferencesDataResponse `json:"preferences"` UpdatedAt string `json:"updated_at"` } // PreferencesDataResponse holds the preference values in the response. type PreferencesDataResponse struct { Theme string `json:"theme"` Language string `json:"language"` Notifications NotificationSettingsResponse `json:"notifications"` } // NotificationSettingsResponse holds notification settings in the response. type NotificationSettingsResponse struct { Email bool `json:"email"` Push bool `json:"push"` Digest string `json:"digest"` } func toPreferencesResponse(p *domain.Preferences) PreferencesResponse { return PreferencesResponse{ UserID: p.UserID.String(), Preferences: PreferencesDataResponse{ Theme: p.Theme, Language: p.Language, Notifications: NotificationSettingsResponse{ Email: p.Notifications.Email, Push: p.Notifications.Push, Digest: p.Notifications.Digest, }, }, UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } // Get returns preferences for a user. func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error { userID := chi.URLParam(r, "user_id") if _, err := uuid.Parse(userID); err != nil { return httperror.BadRequest("invalid user_id format") } if err := authorizeAccess(r, userID); err != nil { return err } prefs, err := h.svc.Get(r.Context(), domain.UserID(userID)) if err != nil { return mapDomainError(err) } httpresponse.OK(w, r, toPreferencesResponse(prefs)) return nil } // Update creates or updates preferences for a user. func (h *Preferences) Update(w http.ResponseWriter, r *http.Request) error { userID := chi.URLParam(r, "user_id") if _, err := uuid.Parse(userID); err != nil { return httperror.BadRequest("invalid user_id format") } if err := authorizeAccess(r, userID); err != nil { return err } // Decode to raw map for manual key validation var raw map[string]json.RawMessage if err := httpresponse.DecodeJSON(r, &raw); err != nil { if errors.Is(err, httpresponse.ErrEmptyBody) { return httperror.BadRequest("request body is required") } return httperror.BadRequest("invalid request body") } prefsRaw, ok := raw["preferences"] if !ok || string(prefsRaw) == "null" { return httperror.BadRequest("preferences field is required") } // Check for unknown top-level keys in request body for key := range raw { if key != "preferences" { return httperror.BadRequest(fmt.Sprintf("unknown field: %s", key)) } } // Parse preferences object and check for unknown keys var prefsMap map[string]json.RawMessage if err := json.Unmarshal(prefsRaw, &prefsMap); err != nil { return httperror.BadRequest("preferences must be a JSON object") } allowedKeys := map[string]bool{"theme": true, "language": true, "notifications": true} for key := range prefsMap { if !allowedKeys[key] { return httperror.BadRequest(fmt.Sprintf("unknown preference key: %s", key)) } } // Parse the preferences payload var payload PreferencesPayload if err := json.Unmarshal(prefsRaw, &payload); err != nil { return httperror.BadRequest("invalid preferences format") } update := toDomainUpdate(&payload) prefs, err := h.svc.Upsert(r.Context(), domain.UserID(userID), update) if err != nil { return mapDomainError(err) } httpresponse.OK(w, r, toPreferencesResponse(prefs)) return nil } func toDomainUpdate(p *PreferencesPayload) *domain.PreferencesUpdate { update := &domain.PreferencesUpdate{ Theme: p.Theme, Language: p.Language, } if p.Notifications != nil { update.Notifications = &domain.NotificationSettingsUpdate{ Email: p.Notifications.Email, Push: p.Notifications.Push, Digest: p.Notifications.Digest, } } return update } // authorizeAccess checks that the authenticated user can access the given user_id. func authorizeAccess(r *http.Request, targetUserID string) error { user := auth.GetUser(r.Context()) if user == nil { // No auth context — when auth is disabled, allow access return nil } if user.ID == targetUserID || user.HasRole("admin") { return nil } return httperror.Forbidden("access denied") } func mapDomainError(err error) error { switch { case errors.Is(err, domain.ErrPreferencesNotFound): return httperror.NotFound("preferences not found") case errors.Is(err, domain.ErrInvalidTheme): return httperror.BadRequest("invalid theme: must be one of light, dark, system") case errors.Is(err, domain.ErrInvalidLanguage): return httperror.BadRequest("invalid language: must be a 2-letter lowercase ISO 639-1 code") case errors.Is(err, domain.ErrInvalidDigest): return httperror.BadRequest("invalid digest: must be one of daily, weekly, never") case errors.Is(err, domain.ErrInvalidPreferences): return httperror.BadRequest("invalid preferences") default: return err } }