package api import ( "encoding/json" "errors" "net/http" "regexp" "strings" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5/pgconn" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // emailRe is a simple format check. We don't try to validate // deliverability — the SMTP send itself is the real check. var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) type updateProfileReq struct { // Pointer fields so the caller can omit-vs-clear distinctly. // nil = leave unchanged; non-nil empty string = clear; non-nil // non-empty = update to that value. Note: distinguishing // omit-vs-clear at JSON-decode time requires custom unmarshaling // or a sentinel; for v1 we use the simpler "empty string clears" // semantics: any field present in the JSON body is applied. DisplayName *string `json:"display_name"` Email *string `json:"email"` } type meProfileResp struct { ID string `json:"id"` Username string `json:"username"` DisplayName *string `json:"display_name"` Email *string `json:"email"` IsAdmin bool `json:"is_admin"` // DebugModeEnabled is the account's diagnostics opt-in. The client // reads it here to decide whether to run the on-device diagnostics // reporter (M9). Admin-set; the client also has a local OFF switch. DebugModeEnabled bool `json:"debug_mode_enabled"` } func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } var req updateProfileReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, apierror.BadRequest("invalid_body", "")) return } // Normalize: trim spaces; treat empty string as "clear" (NULL). var displayName, email *string if req.DisplayName != nil { s := strings.TrimSpace(*req.DisplayName) if s == "" { displayName = nil } else { displayName = &s } } if req.Email != nil { s := strings.ToLower(strings.TrimSpace(*req.Email)) if s == "" { email = nil } else { if !emailRe.MatchString(s) { writeErr(w, apierror.BadRequest("email_invalid", "")) return } email = &s } } // If neither field was provided, no-op (just re-fetch and return). q := dbq.New(h.pool) if req.DisplayName == nil && req.Email == nil { current, err := q.GetUserByID(r.Context(), user.ID) if err != nil { h.logger.Error("update profile: lookup failed", "err", err) writeErr(w, apierror.Internal(err)) return } writeJSON(w, http.StatusOK, profileViewFromUser(current)) return } // To preserve existing values for fields not in the request, we need // to fetch the current row and merge. current, err := q.GetUserByID(r.Context(), user.ID) if err != nil { h.logger.Error("update profile: lookup failed", "err", err) writeErr(w, apierror.Internal(err)) return } if req.DisplayName == nil { displayName = current.DisplayName } if req.Email == nil { email = current.Email } updated, err := q.UpdateUserProfile(r.Context(), dbq.UpdateUserProfileParams{ ID: user.ID, DisplayName: displayName, Email: email, }) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { writeErr(w, apierror.Conflict("email_taken", "")) return } h.logger.Error("update profile: update failed", "err", err) writeErr(w, apierror.Internal(err)) return } writeJSON(w, http.StatusOK, profileViewFromUser(updated)) } func profileViewFromUser(u dbq.User) meProfileResp { return meProfileResp{ ID: uuidToString(u.ID), Username: u.Username, DisplayName: u.DisplayName, Email: u.Email, IsAdmin: u.IsAdmin, DebugModeEnabled: u.DebugModeEnabled, } }