211 lines
6.4 KiB
Go
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
|
|
}
|
|
}
|