refactor(store): single account name on users; rename profile/laps tables
users.display_name becomes users.name and is now the only human-facing name -- profiles.name is dropped (it duplicated the account name; the Profile page's Name field now edits users.name through PUT /api/profile, which carries Name alongside the profiles columns). The profile table becomes profiles and laps becomes activity_laps, homogeneous with activity_samples. Onboarding pre-fills the display name from the OIDC claim's name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -715,7 +715,7 @@ func itoa(v int64) string {
|
||||
}
|
||||
|
||||
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
s, db, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||
@@ -733,10 +733,18 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||
got.GarminEmail = "runner@example.com"
|
||||
got.GarminPassword = "hunter2"
|
||||
got.RollingWindowDays = 120
|
||||
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
|
||||
// Name rides along in the profile payload but lives on the users row.
|
||||
rec = doJSON(t, router, http.MethodPut, "/api/profile", struct {
|
||||
store.Profile
|
||||
Name string
|
||||
}{got, "Renamed Runner"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found || u.Name != "Renamed Runner" {
|
||||
t.Fatalf("users.name after profile PUT = %q (found=%v err=%v), want Renamed Runner", u.Name, found, err)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||
var updated store.Profile
|
||||
@@ -1146,7 +1154,7 @@ func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if entry["msg"] != "http request" {
|
||||
if entry["msg"] != "HTTP request" {
|
||||
t.Errorf("msg = %v, want \"http request\"", entry["msg"])
|
||||
}
|
||||
if entry["method"] != "GET" || entry["path"] != "/api/health" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
@@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// profilePayload is the profile API's request/response shape: the profiles
|
||||
// row plus Name, which lives on the users row (the account's single
|
||||
// human-facing name) but is edited from the same Profile screen.
|
||||
type profilePayload struct {
|
||||
store.Profile
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
p, err := s.DB.GetProfile(r.Context(), userID)
|
||||
@@ -42,22 +51,31 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
u, _ := userFromContext(r.Context())
|
||||
writeJSON(w, http.StatusOK, profilePayload{Profile: p, Name: u.Name})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
var p store.Profile
|
||||
var p profilePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := validateProfile(p); err != nil {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if err := validateProfile(p.Profile); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil {
|
||||
if err := s.DB.UpdateProfile(r.Context(), userID, p.Profile); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.DB.UpdateUserName(r.Context(), userID, strings.TrimSpace(p.Name)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -73,7 +91,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
writeJSON(w, http.StatusOK, profilePayload{Profile: updated, Name: strings.TrimSpace(p.Name)})
|
||||
}
|
||||
|
||||
// handleDeleteProfile permanently deletes the signed-in user's entire
|
||||
|
||||
@@ -184,7 +184,7 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "http request",
|
||||
logger.LogAttrs(r.Context(), level, "HTTP request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
|
||||
@@ -114,7 +114,7 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
||||
if u, found := userFromContext(r.Context()); found {
|
||||
resp.HasProfile = true
|
||||
resp.DisplayName = u.DisplayName
|
||||
resp.DisplayName = u.Name
|
||||
profile, err := s.DB.GetProfile(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -14,8 +14,8 @@ const resolvedUserContextKey userContextKey = iota
|
||||
// resolvedUser is the geniusrun account (if any) bound to the current
|
||||
// session's OIDC subject.
|
||||
type resolvedUser struct {
|
||||
ID int64
|
||||
DisplayName string
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
// resolveUser runs after auth.RequireSession on every request and looks up
|
||||
@@ -40,7 +40,7 @@ func (s *Server) resolveUser(next http.Handler) http.Handler {
|
||||
}
|
||||
ctx := r.Context()
|
||||
if found {
|
||||
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
|
||||
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, Name: u.Name})
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user