slate-v3-1770514618/services/preferences-api/internal/api/handlers/preferences.go
rdev-worker 1afe983cd6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /implement-feature user-preferences
2026-02-08 02:02:18 +00:00

211 lines
6.4 KiB
Go

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