slack5-1770574304/services/preferences-api/internal/api/handlers/preferences.go
rdev-worker 5fa5a77bfb
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /implement-feature user-preferences
2026-02-08 18:36:52 +00:00

155 lines
4.8 KiB
Go

package handlers
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770574304/pkg/app"
"git.threesix.ai/jordan/slack5-1770574304/pkg/auth"
"git.threesix.ai/jordan/slack5-1770574304/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770574304/pkg/httpresponse"
"git.threesix.ai/jordan/slack5-1770574304/pkg/logging"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/service"
)
// Preference handles HTTP requests for user preference resources.
type Preference struct {
svc *service.PreferenceService
logger *logging.Logger
}
// NewPreference creates a new Preference handler with injected dependencies.
func NewPreference(svc *service.PreferenceService, logger *logging.Logger) *Preference {
return &Preference{
svc: svc,
logger: logger.WithComponent("PreferenceHandler"),
}
}
// UpdatePreferencesRequest is the request body for updating preferences.
type UpdatePreferencesRequest struct {
Theme string `json:"theme" validate:"required,oneof=light dark system"`
Language string `json:"language" validate:"required,oneof=en fr es de ja"`
Notifications UpdateNotificationsRequest `json:"notifications" validate:"required"`
}
// UpdateNotificationsRequest is the nested notification preferences in the request.
type UpdateNotificationsRequest struct {
Email bool `json:"email"`
Push bool `json:"push"`
Digest string `json:"digest" validate:"required,oneof=none daily weekly"`
}
// PreferencesResponse is the API response for user preferences.
type PreferencesResponse struct {
UserID string `json:"user_id"`
Theme string `json:"theme"`
Language string `json:"language"`
Notifications NotificationsResponse `json:"notifications"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// NotificationsResponse is the nested notification preferences in the response.
type NotificationsResponse struct {
Email bool `json:"email"`
Push bool `json:"push"`
Digest string `json:"digest"`
}
// toPreferencesResponse converts domain preferences to an API response.
func toPreferencesResponse(p *domain.UserPreferences) PreferencesResponse {
resp := PreferencesResponse{
UserID: p.UserID,
Theme: string(p.Theme),
Language: p.Language,
Notifications: NotificationsResponse{
Email: p.Notifications.Email,
Push: p.Notifications.Push,
Digest: string(p.Notifications.Digest),
},
}
if !p.UpdatedAt.IsZero() {
resp.UpdatedAt = p.UpdatedAt.Format(time.RFC3339)
}
return resp
}
// Get returns preferences for a user.
func (h *Preference) Get(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
// Authorization: self-access or admin read
if user.ID != userID && !user.HasRole("admin") {
return httperror.Forbidden("access denied: cannot access another user's preferences")
}
prefs, err := h.svc.GetPreferences(r.Context(), userID)
if err != nil {
return err
}
httpresponse.OK(w, r, toPreferencesResponse(prefs))
return nil
}
// Update creates or replaces preferences for a user.
func (h *Preference) Update(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
// Authorization: self-access only (even admins cannot write other users' preferences)
if user.ID != userID {
return httperror.Forbidden("access denied: cannot modify another user's preferences")
}
var req UpdatePreferencesRequest
if err := app.BindAndValidateStrict(r, &req); err != nil {
return err
}
prefs := &domain.UserPreferences{
Theme: domain.Theme(req.Theme),
Language: req.Language,
Notifications: domain.NotificationPreferences{
Email: req.Notifications.Email,
Push: req.Notifications.Push,
Digest: domain.DigestFrequency(req.Notifications.Digest),
},
}
result, err := h.svc.UpdatePreferences(r.Context(), userID, prefs)
if err != nil {
return mapPreferenceDomainError(err)
}
httpresponse.OK(w, r, toPreferencesResponse(result))
return nil
}
// mapPreferenceDomainError converts domain errors to HTTP errors.
func mapPreferenceDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidTheme):
return httperror.BadRequest(domain.ErrInvalidTheme.Error())
case errors.Is(err, domain.ErrInvalidLanguage):
return httperror.BadRequest(domain.ErrInvalidLanguage.Error())
case errors.Is(err, domain.ErrInvalidDigest):
return httperror.BadRequest(domain.ErrInvalidDigest.Error())
default:
return err
}
}